diff --git a/.github/workflows/docker-build-frontend.yml b/.github/workflows/docker-build-frontend.yml
index b896fc00..d7833a42 100644
--- a/.github/workflows/docker-build-frontend.yml
+++ b/.github/workflows/docker-build-frontend.yml
@@ -87,6 +87,10 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ build-args: |
+ BUILDKIT_INLINE_CACHE=1
# https://github.com/peter-evans/dockerhub-description
- name: Update Docker Hub description
diff --git a/ichub-frontend/Dockerfile b/ichub-frontend/Dockerfile
index 1f5dc909..6427d687 100644
--- a/ichub-frontend/Dockerfile
+++ b/ichub-frontend/Dockerfile
@@ -23,17 +23,26 @@
# Stage 1: Build the React app
FROM node:23-alpine AS builder
+# Set working directory first
+WORKDIR /app
+
# Copy package files and install dependencies
COPY package.json package-lock.json ./
-RUN npm ci --ignore-scripts
-# Copy the rest of the source code
-COPY . /app
+# Install dependencies with optimizations for Docker
+RUN npm ci --include=dev --ignore-scripts --prefer-offline
-# Set the working directory
-WORKDIR /app
+# Copy only necessary source files for better caching
+COPY src/ ./src/
+COPY public/ ./public/
+COPY index.html ./
+COPY vite.config.ts ./
+COPY tsconfig*.json ./
+
+# Set production environment for optimized build
+ENV NODE_ENV=production
-# Build the application
+# Build the application with optimized settings
RUN npm run build
diff --git a/ichub-frontend/package-lock.json b/ichub-frontend/package-lock.json
index 49f07f46..eba841bc 100644
--- a/ichub-frontend/package-lock.json
+++ b/ichub-frontend/package-lock.json
@@ -6389,4 +6389,4 @@
}
}
}
-}
+}
\ No newline at end of file
diff --git a/ichub-frontend/package.json b/ichub-frontend/package.json
index 169c6407..290324c9 100644
--- a/ichub-frontend/package.json
+++ b/ichub-frontend/package.json
@@ -5,7 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite",
- "build": "tsc -b && vite build",
+ "build": "vite build",
+ "build:full": "tsc -b && vite build",
+ "typecheck": "tsc --noEmit",
"lint": "eslint .",
"preview": "vite preview",
"build:docker": "IMAGE=ichub-frontend && docker build -t $IMAGE -f Dockerfile .",
@@ -41,4 +43,4 @@
"vite": "^6.1.0",
"vitest": "^3.2.4"
}
-}
+}
\ No newline at end of file
diff --git a/ichub-frontend/src/components/general/AdditionalSidebar.tsx b/ichub-frontend/src/components/general/AdditionalSidebar.tsx
new file mode 100644
index 00000000..99a9450d
--- /dev/null
+++ b/ichub-frontend/src/components/general/AdditionalSidebar.tsx
@@ -0,0 +1,53 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import React from 'react';
+import { Box } from '@mui/material';
+import { useAdditionalSidebar } from '../../hooks/useAdditionalSidebar';
+
+const AdditionalSidebar: React.FC = () => {
+ const { isVisible, content } = useAdditionalSidebar();
+
+ return (
+
+
+ {content}
+
+
+ );
+};
+
+export default AdditionalSidebar;
diff --git a/ichub-frontend/src/components/layout/AdditionalSidebar.tsx b/ichub-frontend/src/components/layout/AdditionalSidebar.tsx
new file mode 100644
index 00000000..bc74b5c0
--- /dev/null
+++ b/ichub-frontend/src/components/layout/AdditionalSidebar.tsx
@@ -0,0 +1,51 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import React from 'react';
+import { Box } from '@mui/material';
+import { useAdditionalSidebar } from '../../hooks/useAdditionalSidebar';
+
+const AdditionalSidebar: React.FC = () => {
+ const { isVisible, content } = useAdditionalSidebar();
+
+ return (
+
+ {content}
+
+ );
+};
+
+export default AdditionalSidebar;
diff --git a/ichub-frontend/src/contexts/AdditionalSidebarContext.tsx b/ichub-frontend/src/contexts/AdditionalSidebarContext.tsx
new file mode 100644
index 00000000..5e101381
--- /dev/null
+++ b/ichub-frontend/src/contexts/AdditionalSidebarContext.tsx
@@ -0,0 +1,70 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import React, { createContext, useState, ReactNode, useCallback } from 'react';
+
+export interface AdditionalSidebarContextType {
+ isVisible: boolean;
+ content: ReactNode;
+ showSidebar: (content: ReactNode) => void;
+ hideSidebar: () => void;
+ toggleSidebar: () => void;
+}
+
+export const AdditionalSidebarContext = createContext(undefined);
+
+interface AdditionalSidebarProviderProps {
+ children: ReactNode;
+}
+
+export const AdditionalSidebarProvider: React.FC = ({ children }) => {
+ const [isVisible, setIsVisible] = useState(false);
+ const [content, setContent] = useState(null);
+
+ const showSidebar = useCallback((newContent: ReactNode) => {
+ setContent(newContent);
+ setIsVisible(true);
+ }, []);
+
+ const hideSidebar = useCallback(() => {
+ setIsVisible(false);
+ setContent(null);
+ }, []);
+
+ const toggleSidebar = useCallback(() => {
+ setIsVisible(!isVisible);
+ }, [isVisible]);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/ichub-frontend/src/contexts/SidebarContext.tsx b/ichub-frontend/src/contexts/SidebarContext.tsx
new file mode 100644
index 00000000..372cdc15
--- /dev/null
+++ b/ichub-frontend/src/contexts/SidebarContext.tsx
@@ -0,0 +1,76 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import React, { createContext, useContext, useState, ReactNode } from 'react';
+
+interface SidebarContextType {
+ isVisible: boolean;
+ content: ReactNode | null;
+ showSidebar: (content: ReactNode) => void;
+ hideSidebar: () => void;
+ toggleSidebar: () => void;
+}
+
+const SidebarContext = createContext(undefined);
+
+export const useSidebar = () => {
+ const context = useContext(SidebarContext);
+ if (!context) {
+ throw new Error('useSidebar must be used within a SidebarProvider');
+ }
+ return context;
+};
+
+interface SidebarProviderProps {
+ children: ReactNode;
+}
+
+export const SidebarProvider: React.FC = ({ children }) => {
+ const [isVisible, setIsVisible] = useState(false);
+ const [content, setContent] = useState(null);
+
+ const showSidebar = (content: ReactNode) => {
+ setContent(content);
+ setIsVisible(true);
+ };
+
+ const hideSidebar = () => {
+ setIsVisible(false);
+ setContent(null);
+ };
+
+ const toggleSidebar = () => {
+ setIsVisible(!isVisible);
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/ichub-frontend/src/features/part-discovery/api.ts b/ichub-frontend/src/features/part-discovery/api.ts
new file mode 100644
index 00000000..3f770666
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/api.ts
@@ -0,0 +1,955 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+import axios from 'axios';
+import {
+ getIchubBackendUrl,
+ getGovernanceConfig,
+ getDtrPoliciesConfig,
+ GovernanceConfig,
+ GovernanceConstraint,
+ GovernanceRule,
+ GovernancePolicy
+} from '../../services/EnvironmentService';
+import { ApiPartData } from '../../types/product';
+import { CatalogPartTwinCreateType, TwinReadType } from '../../types/twin';
+import { ShellDiscoveryResponse, getNextPageCursor, getPreviousPageCursor, hasNextPage, hasPreviousPage } from './utils';
+
+const CATALOG_PART_MANAGEMENT_BASE_PATH = '/part-management/catalog-part';
+const SHARE_CATALOG_PART_BASE_PATH = '/share/catalog-part';
+const TWIN_MANAGEMENT_BASE_PATH = '/twin-management/catalog-part-twin';
+const SHELL_DISCOVERY_BASE_PATH = '/discover/shells';
+const backendUrl = getIchubBackendUrl();
+
+// Cache system with configuration change detection
+interface PolicyCache {
+ configHash: string;
+ policies: OdrlPolicy[];
+}
+
+let dtrGovernancePoliciesCache: PolicyCache | null = null;
+const governancePoliciesCache: Map = new Map();
+let defaultGovernancePolicyCache: PolicyCache | null = null;
+
+/**
+ * Generate a SHA-256 hash from an object for cache invalidation
+ */
+const generateConfigHash = async (config: unknown): Promise => {
+ const configString = JSON.stringify(config, null, 0);
+ const encoder = new TextEncoder();
+ const data = encoder.encode(configString);
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data);
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
+};
+
+/**
+ * Generate DTR policies with all constraint permutations
+ */
+const generateDtrPoliciesWithPermutations = (dtrPolicies: GovernancePolicy[]): OdrlPolicy[] => {
+ const allPolicyPermutations: OdrlPolicy[] = [];
+
+ for (const policy of dtrPolicies) {
+ // Generate permutations for each rule type
+ const permissionPermutations = generateRulesPermutations(policy.permission);
+ const prohibitionPermutations = generateRulesPermutations(policy.prohibition);
+ const obligationPermutations = generateRulesPermutations(policy.obligation);
+
+ // Create cartesian product of all rule permutations
+ for (const permission of permissionPermutations) {
+ for (const prohibition of prohibitionPermutations) {
+ for (const obligation of obligationPermutations) {
+ allPolicyPermutations.push({
+ "odrl:permission": convertRulesToOdrl(permission),
+ "odrl:prohibition": convertRulesToOdrl(prohibition),
+ "odrl:obligation": convertRulesToOdrl(obligation)
+ });
+ }
+ }
+ }
+ }
+
+ return allPolicyPermutations;
+};
+
+/**
+ * Generate governance policies with permutations for a specific semantic ID
+ */
+const generateGovernancePoliciesWithPermutations = async (semanticId: string, config: GovernanceConfig[]): Promise => {
+ // First, try to find a specific configuration for the semantic ID
+ const relevantConfig = config.find(cfg => cfg.semanticid === semanticId);
+
+ if (relevantConfig) {
+ // Found specific configuration, use it
+ return generateDtrPoliciesWithPermutations(relevantConfig.policies);
+ }
+
+ // No specific configuration found, use default policies as fallback
+ console.log(`No specific governance configuration found for semantic ID: ${semanticId}, using default policies`);
+ return await getCachedDefaultGovernancePolicies();
+};
+
+/**
+ * Generate default governance policy permutations
+ */
+const generateDefaultGovernancePolicyPermutations = (): OdrlPolicy[] => {
+ // Default policies with constraints that need permutations
+ const defaultPolicy: GovernancePolicy = {
+ strict: false,
+ permission: {
+ action: 'odrl:use',
+ LogicalConstraint: 'odrl:and',
+ constraints: [
+ {
+ leftOperand: 'cx-policy:FrameworkAgreement',
+ operator: 'odrl:eq',
+ rightOperand: 'DataExchangeGovernance:1.0'
+ },
+ {
+ leftOperand: 'cx-policy:Membership',
+ operator: 'odrl:eq',
+ rightOperand: 'active'
+ },
+ {
+ leftOperand: 'cx-policy:UsagePurpose',
+ operator: 'odrl:eq',
+ rightOperand: 'cx.core.industrycore:1'
+ }
+ ]
+ },
+ prohibition: [],
+ obligation: []
+ };
+
+ return generateDtrPoliciesWithPermutations([defaultPolicy]);
+};
+
+/**
+ * Get cached DTR governance policies with configuration change detection
+ */
+const getCachedDtrGovernancePolicies = async (): Promise => {
+ const currentConfig = getDtrPoliciesConfig();
+ const currentHash = await generateConfigHash(currentConfig);
+
+ // Check if cache is valid
+ if (dtrGovernancePoliciesCache && dtrGovernancePoliciesCache.configHash === currentHash) {
+ return dtrGovernancePoliciesCache.policies;
+ }
+
+ // Cache is invalid or doesn't exist, regenerate
+ console.log('DTR governance policies cache invalidated, regenerating...');
+ const newPolicies = generateDtrPoliciesWithPermutations(currentConfig);
+
+ dtrGovernancePoliciesCache = {
+ configHash: currentHash,
+ policies: newPolicies
+ };
+
+ return newPolicies;
+};
+
+/**
+ * Get cached governance policies for a specific semantic ID
+ */
+const getCachedGovernancePolicies = async (semanticId: string): Promise => {
+ const currentConfig = getGovernanceConfig();
+ const currentHash = await generateConfigHash(currentConfig);
+
+ // Check if cache is valid for this semantic ID
+ const cached = governancePoliciesCache.get(semanticId);
+ if (cached && cached.configHash === currentHash) {
+ return cached.policies;
+ }
+
+ // Cache is invalid or doesn't exist, regenerate
+ console.log(`Governance policies cache invalidated for semantic ID: ${semanticId}, regenerating...`);
+ const newPolicies = await generateGovernancePoliciesWithPermutations(semanticId, currentConfig);
+
+ governancePoliciesCache.set(semanticId, {
+ configHash: currentHash,
+ policies: newPolicies
+ });
+
+ return newPolicies;
+};
+
+/**
+ * Get cached default governance policies
+ */
+const getCachedDefaultGovernancePolicies = async (): Promise => {
+ // Default policies don't depend on configuration, but we still cache them
+ // Use a static hash since default policies don't change based on config
+ const staticHash = 'default_v1';
+
+ if (defaultGovernancePolicyCache && defaultGovernancePolicyCache.configHash === staticHash) {
+ return defaultGovernancePolicyCache.policies;
+ }
+
+ console.log('Default governance policies cache invalidated, regenerating...');
+ const newPolicies = generateDefaultGovernancePolicyPermutations();
+
+ defaultGovernancePolicyCache = {
+ configHash: staticHash,
+ policies: newPolicies
+ };
+
+ return newPolicies;
+};
+
+// Types for ODRL policies with flexible structure
+interface OdrlConstraint {
+ "odrl:leftOperand": { "@id": string };
+ "odrl:operator": { "@id": string };
+ "odrl:rightOperand": string;
+}
+
+// Support for logical operators: "and", "or", or single constraint
+interface OdrlRule {
+ "odrl:action": { "@id": string };
+ "odrl:constraint"?:
+ | { "odrl:and": OdrlConstraint[] } // Multiple constraints with AND logic
+ | { "odrl:or": OdrlConstraint[] } // Multiple constraints with OR logic
+ | OdrlConstraint; // Single constraint without logical operator
+}
+
+interface OdrlPolicy {
+ "odrl:permission": OdrlRule | OdrlRule[]; // Single object when 1 rule, array when multiple
+ "odrl:prohibition": OdrlRule | OdrlRule[]; // Single object when 1 rule, array when multiple
+ "odrl:obligation": OdrlRule | OdrlRule[]; // Single object when 1 rule, array when multiple
+}
+
+// Types for Shell Discovery API requests
+export interface QuerySpecItem {
+ name: string;
+ value: string;
+}
+
+export interface ShellDiscoveryRequest {
+ counterPartyId: string;
+ querySpec: QuerySpecItem[];
+ limit?: number;
+ cursor?: string;
+ dtrGovernance?: OdrlPolicy[];
+}
+
+export const fetchCatalogParts = async (): Promise => {
+ const response = await axios.get(`${backendUrl}${CATALOG_PART_MANAGEMENT_BASE_PATH}`);
+ return response.data;
+};
+
+export const fetchCatalogPart = async (
+ manufacturerId: string ,
+ manufacturerPartId: string
+): Promise => {
+ const response = await axios.get(
+ `${backendUrl}${CATALOG_PART_MANAGEMENT_BASE_PATH}/${manufacturerId}/${manufacturerPartId}`
+ );
+ return response.data;
+};
+
+export const shareCatalogPart = async (
+ manufacturerId: string,
+ manufacturerPartId: string,
+ businessPartnerNumber: string,
+ customerPartId?: string
+): Promise => {
+ const requestBody: {
+ manufacturerId: string;
+ manufacturerPartId: string;
+ businessPartnerNumber: string;
+ customerPartId?: string;
+ } = {
+ manufacturerId,
+ manufacturerPartId,
+ businessPartnerNumber,
+ customerPartId: customerPartId && customerPartId.trim() ? customerPartId.trim() : undefined,
+ };
+
+ const response = await axios.post(
+ `${backendUrl}${SHARE_CATALOG_PART_BASE_PATH}`,
+ requestBody
+ );
+ return response.data;
+};
+
+export const registerCatalogPartTwin = async (
+ twinData: CatalogPartTwinCreateType
+): Promise => {
+ const response = await axios.post(
+ `${backendUrl}${TWIN_MANAGEMENT_BASE_PATH}`,
+ twinData
+ );
+ return response.data;
+};
+
+// Shell Discovery API Functions
+
+/**
+ * Discover shells based on query specifications
+ */
+export const discoverShells = async (
+ request: ShellDiscoveryRequest
+): Promise => {
+ const response = await axios.post(
+ `${backendUrl}${SHELL_DISCOVERY_BASE_PATH}`,
+ request
+ );
+ return response.data;
+};
+
+/**
+ * Discover shells for a specific counter party and digital twin type
+ */
+export const discoverShellsByType = async (
+ counterPartyId: string,
+ digitalTwinType: string,
+ limit?: number,
+ cursor?: string
+): Promise => {
+ const dtrPolicies = await convertDtrPoliciesToOdrl();
+
+ const request: ShellDiscoveryRequest = {
+ counterPartyId,
+ querySpec: [
+ {
+ name: 'digitalTwinType',
+ value: digitalTwinType
+ }
+ ],
+ dtrGovernance: dtrPolicies,
+ ...(limit && { limit }),
+ ...(cursor && { cursor })
+ };
+
+ return discoverShells(request);
+};
+
+export const discoverShellsByCustomerPartId = async (
+ counterPartyId: string,
+ customerPartId: string,
+ limit?: number,
+ cursor?: string
+): Promise => {
+ const dtrPolicies = await convertDtrPoliciesToOdrl();
+
+ const request: ShellDiscoveryRequest = {
+ counterPartyId,
+ querySpec: [
+ {
+ name: 'customerPartId',
+ value: customerPartId
+ }
+ ],
+ dtrGovernance: dtrPolicies,
+ ...(limit && { limit }),
+ ...(cursor && { cursor })
+ };
+
+ return discoverShells(request);
+};
+
+/**
+ * Discover PartType shells for a specific counter party
+ */
+export const discoverPartTypeShells = async (
+ counterPartyId: string,
+ limit?: number,
+ cursor?: string
+): Promise => {
+ return discoverShellsByType(counterPartyId, 'PartType', limit, cursor);
+};
+
+export const discoverPartInstanceShells = async (
+ counterPartyId: string,
+ limit?: number,
+ cursor?: string
+): Promise => {
+ return discoverShellsByType(counterPartyId, 'PartInstance', limit, cursor);
+};
+
+/**
+ * Get next page of shell discovery results
+ */
+export const getNextShellsPage = async (
+ counterPartyId: string,
+ digitalTwinType: string,
+ nextCursor: string,
+ limit?: number
+): Promise => {
+ return discoverShellsByType(counterPartyId, digitalTwinType, limit, nextCursor);
+};
+
+/**
+ * Get previous page of shell discovery results
+ */
+export const getPreviousShellsPage = async (
+ counterPartyId: string,
+ digitalTwinType: string,
+ previousCursor: string,
+ limit?: number
+): Promise => {
+ return discoverShellsByType(counterPartyId, digitalTwinType, limit, previousCursor);
+};
+
+/**
+ * Discover shells with custom query specifications
+ */
+export const discoverShellsWithCustomQuery = async (
+ counterPartyId: string,
+ querySpec: QuerySpecItem[],
+ limit?: number,
+ cursor?: string
+): Promise => {
+ const dtrPolicies = await convertDtrPoliciesToOdrl();
+
+ const request: ShellDiscoveryRequest = {
+ counterPartyId,
+ querySpec,
+ dtrGovernance: dtrPolicies,
+ ...(limit && { limit }),
+ ...(cursor && { cursor })
+ };
+
+ return discoverShells(request);
+};
+
+// Types for Single Shell Discovery
+export interface SingleShellDiscoveryRequest {
+ counterPartyId: string;
+ id: string;
+ dtrGovernance?: OdrlPolicy[];
+}
+
+export interface SingleShellDiscoveryResponse {
+ shell_descriptor: {
+ description: unknown[];
+ displayName: unknown[];
+ globalAssetId: string;
+ id: string;
+ idShort: string;
+ specificAssetIds: Array<{
+ supplementalSemanticIds: unknown[];
+ name: string;
+ value: string;
+ externalSubjectId: {
+ type: string;
+ keys: Array<{
+ type: string;
+ value: string;
+ }>;
+ };
+ }>;
+ submodelDescriptors: Array<{
+ endpoints: Array<{
+ interface: string;
+ protocolInformation: {
+ href: string;
+ endpointProtocol: string;
+ endpointProtocolVersion: string[];
+ subprotocol: string;
+ subprotocolBody: string;
+ subprotocolBodyEncoding: string;
+ securityAttributes: Array<{
+ type: string;
+ key: string;
+ value: string;
+ }>;
+ };
+ }>;
+ idShort: string;
+ id: string;
+ semanticId: {
+ type: string;
+ keys: Array<{
+ type: string;
+ value: string;
+ }>;
+ };
+ supplementalSemanticId: unknown[];
+ description: unknown[];
+ displayName: unknown[];
+ }>;
+ };
+ dtr: {
+ connectorUrl: string;
+ assetId: string;
+ };
+}
+
+/**
+ * Discover a single shell by AAS ID
+ */
+export const discoverSingleShell = async (
+ counterPartyId: string,
+ aasId: string
+): Promise => {
+ const dtrPolicies = await convertDtrPoliciesToOdrl();
+
+ const request: SingleShellDiscoveryRequest = {
+ counterPartyId,
+ id: aasId,
+ dtrGovernance: dtrPolicies
+ };
+
+ const response = await axios.post(
+ `${backendUrl}/discover/shell`,
+ request
+ );
+ return response.data;
+};
+
+// Enhanced pagination functions that automatically extract cursors
+
+/**
+ * Get the next page of results using the current response
+ */
+export const getNextPageFromResponse = async (
+ currentResponse: ShellDiscoveryResponse,
+ counterPartyId: string,
+ digitalTwinType: string,
+ limit?: number
+): Promise => {
+ const nextCursor = getNextPageCursor(currentResponse);
+ if (!nextCursor) {
+ return null; // No more pages
+ }
+
+ return discoverShellsByType(counterPartyId, digitalTwinType, limit, nextCursor);
+};
+
+/**
+ * Get the previous page of results using the current response
+ */
+export const getPreviousPageFromResponse = async (
+ currentResponse: ShellDiscoveryResponse,
+ counterPartyId: string,
+ digitalTwinType: string,
+ limit?: number
+): Promise => {
+ const previousCursor = getPreviousPageCursor(currentResponse);
+ if (!previousCursor) {
+ return null; // No previous pages
+ }
+
+ return discoverShellsByType(counterPartyId, digitalTwinType, limit, previousCursor);
+};
+
+/**
+ * Get next page for custom query results
+ */
+export const getNextPageFromCustomQuery = async (
+ currentResponse: ShellDiscoveryResponse,
+ counterPartyId: string,
+ querySpec: QuerySpecItem[],
+ limit?: number
+): Promise => {
+ const nextCursor = getNextPageCursor(currentResponse);
+ if (!nextCursor) {
+ return null;
+ }
+
+ return discoverShellsWithCustomQuery(counterPartyId, querySpec, limit, nextCursor);
+};
+
+/**
+ * Get previous page for custom query results
+ */
+export const getPreviousPageFromCustomQuery = async (
+ currentResponse: ShellDiscoveryResponse,
+ counterPartyId: string,
+ querySpec: QuerySpecItem[],
+ limit?: number
+): Promise => {
+ const previousCursor = getPreviousPageCursor(currentResponse);
+ if (!previousCursor) {
+ return null;
+ }
+
+ return discoverShellsWithCustomQuery(counterPartyId, querySpec, limit, previousCursor);
+};
+
+/**
+ * Pagination helper that provides easy navigation methods
+ */
+export class ShellDiscoveryPaginator {
+ private currentResponse: ShellDiscoveryResponse;
+ private counterPartyId: string;
+ private digitalTwinType?: string;
+ private querySpec?: QuerySpecItem[];
+ private limit?: number;
+
+ constructor(
+ currentResponse: ShellDiscoveryResponse,
+ counterPartyId: string,
+ digitalTwinTypeOrQuerySpec?: string | QuerySpecItem[],
+ limit?: number
+ ) {
+ this.currentResponse = currentResponse;
+ this.counterPartyId = counterPartyId;
+ this.limit = limit;
+
+ if (typeof digitalTwinTypeOrQuerySpec === 'string') {
+ this.digitalTwinType = digitalTwinTypeOrQuerySpec;
+ } else if (Array.isArray(digitalTwinTypeOrQuerySpec)) {
+ this.querySpec = digitalTwinTypeOrQuerySpec;
+ }
+ }
+
+ /**
+ * Check if next page is available
+ */
+ hasNext(): boolean {
+ return hasNextPage(this.currentResponse);
+ }
+
+ /**
+ * Check if previous page is available
+ */
+ hasPrevious(): boolean {
+ return hasPreviousPage(this.currentResponse);
+ }
+
+ /**
+ * Get next page and update current response
+ */
+ async next(): Promise {
+ let nextResponse: ShellDiscoveryResponse | null = null;
+
+ if (this.digitalTwinType) {
+ nextResponse = await getNextPageFromResponse(
+ this.currentResponse,
+ this.counterPartyId,
+ this.digitalTwinType,
+ this.limit
+ );
+ } else if (this.querySpec) {
+ nextResponse = await getNextPageFromCustomQuery(
+ this.currentResponse,
+ this.counterPartyId,
+ this.querySpec,
+ this.limit
+ );
+ }
+
+ if (nextResponse) {
+ this.currentResponse = nextResponse;
+ }
+
+ return nextResponse;
+ }
+
+ /**
+ * Get previous page and update current response
+ */
+ async previous(): Promise {
+ let previousResponse: ShellDiscoveryResponse | null = null;
+
+ if (this.digitalTwinType) {
+ previousResponse = await getPreviousPageFromResponse(
+ this.currentResponse,
+ this.counterPartyId,
+ this.digitalTwinType,
+ this.limit
+ );
+ } else if (this.querySpec) {
+ previousResponse = await getPreviousPageFromCustomQuery(
+ this.currentResponse,
+ this.counterPartyId,
+ this.querySpec,
+ this.limit
+ );
+ }
+
+ if (previousResponse) {
+ this.currentResponse = previousResponse;
+ }
+
+ return previousResponse;
+ }
+
+ /**
+ * Get current response
+ */
+ getCurrentResponse(): ShellDiscoveryResponse {
+ return this.currentResponse;
+ }
+
+ /**
+ * Get pagination info
+ */
+ getPaginationInfo() {
+ return {
+ currentPage: this.currentResponse.pagination.page,
+ shellsFound: this.currentResponse.shellsFound,
+ hasNext: this.hasNext(),
+ hasPrevious: this.hasPrevious()
+ };
+ }
+}
+
+// Submodel Discovery API Functions
+
+export interface SubmodelDiscoveryRequest {
+ counterPartyId: string;
+ id: string;
+ submodelId: string;
+ dtrGovernance?: OdrlPolicy[];
+ governance: OdrlPolicy[];
+}
+
+export interface SubmodelDiscoveryResponse {
+ submodelDescriptor: {
+ submodelId: string;
+ semanticId: string;
+ semanticIdKeys: string;
+ assetId: string;
+ connectorUrl: string;
+ href: string;
+ status: string;
+ error?: string;
+ };
+ submodel: Record;
+ dtr: {
+ connectorUrl: string;
+ assetId: string;
+ } | null;
+ status?: string;
+ error?: string;
+}
+
+/**
+ * Convert constraint to ODRL format
+ */
+const convertConstraintToOdrl = (constraint: GovernanceConstraint): OdrlConstraint => ({
+ "odrl:leftOperand": {
+ "@id": constraint.leftOperand
+ },
+ "odrl:operator": {
+ "@id": constraint.operator
+ },
+ "odrl:rightOperand": constraint.rightOperand
+});
+
+/**
+ * Generate all permutations of an array
+ */
+const generatePermutations = (arr: T[]): T[][] => {
+ if (arr.length <= 1) return [arr];
+
+ const result: T[][] = [];
+ for (let i = 0; i < arr.length; i++) {
+ const current = arr[i];
+ const remaining = [...arr.slice(0, i), ...arr.slice(i + 1)];
+ const perms = generatePermutations(remaining);
+
+ for (const perm of perms) {
+ result.push([current, ...perm]);
+ }
+ }
+
+ return result;
+};
+
+/**
+ * Generate all permutations of constraints within a rule, creating separate policies for each ordering
+ */
+const generateRulePermutations = (rule: GovernanceRule): GovernanceRule[] => {
+ if (!rule.constraints || rule.constraints.length <= 1) {
+ return [rule];
+ }
+
+ const constraintPermutations = generatePermutations(rule.constraints);
+
+ return constraintPermutations.map(permutedConstraints => ({
+ ...rule,
+ constraints: permutedConstraints
+ }));
+};
+
+/**
+ * Generate all permutations of rules (permission, prohibition, obligation)
+ */
+const generateRulesPermutations = (rules: GovernanceRule | GovernanceRule[]): (GovernanceRule | GovernanceRule[])[] => {
+ if (Array.isArray(rules)) {
+ if (rules.length === 0) return [rules];
+
+ // For array of rules, generate permutations for each rule
+ const rulePermutations = rules.map(generateRulePermutations);
+
+ // Generate cartesian product of all rule permutations
+ const result: GovernanceRule[][] = [[]];
+ for (const permutations of rulePermutations) {
+ const newResult: GovernanceRule[][] = [];
+ for (const existing of result) {
+ for (const perm of permutations) {
+ newResult.push([...existing, perm]);
+ }
+ }
+ result.length = 0;
+ result.push(...newResult);
+ }
+
+ return result;
+ } else {
+ // Single rule - generate permutations for its constraints
+ return generateRulePermutations(rules);
+ }
+};
+
+/**
+ * Create constraint structure based on logical operator and constraints
+ */
+const createConstraintStructure = (
+ constraints: GovernanceConstraint[],
+ logicalOperator?: string
+): OdrlConstraint | { "odrl:and": OdrlConstraint[] } | { "odrl:or": OdrlConstraint[] } => {
+ const odrlConstraints = constraints.map(convertConstraintToOdrl);
+
+ // Single constraint without logical operator
+ if (odrlConstraints.length === 1 && !logicalOperator) {
+ return odrlConstraints[0];
+ }
+
+ // Multiple constraints with logical operators
+ if (logicalOperator === "odrl:or" || logicalOperator === "or") {
+ return { "odrl:or": odrlConstraints };
+ }
+
+ // Default to "and" for multiple constraints
+ return { "odrl:and": odrlConstraints };
+};
+
+/**
+ * Convert rule (permission, prohibition, obligation) to ODRL format
+ */
+const convertRuleToOdrl = (
+ rule: { action: string; constraints: GovernanceConstraint[]; LogicalConstraint?: string }
+): OdrlRule => {
+ const odrlRule: OdrlRule = {
+ "odrl:action": {
+ "@id": rule.action
+ }
+ };
+
+ if (rule.constraints && rule.constraints.length > 0) {
+ odrlRule["odrl:constraint"] = createConstraintStructure(
+ rule.constraints,
+ rule.LogicalConstraint
+ );
+ }
+
+ return odrlRule;
+};
+
+/**
+ * Convert governance rule(s) to ODRL format
+ * - Single rule -> single OdrlRule object
+ * - Multiple rules -> array of OdrlRule objects
+ */
+const convertRulesToOdrl = (rules: GovernanceRule | GovernanceRule[]): OdrlRule | OdrlRule[] => {
+ if (Array.isArray(rules)) {
+ // If it's an array, check the length
+ if (rules.length === 1) {
+ // Single item in array -> return as single object
+ return convertRuleToOdrl(rules[0]);
+ } else {
+ // Multiple items -> return as array
+ return rules.map(convertRuleToOdrl);
+ }
+ } else {
+ // Single object -> return as single object
+ return convertRuleToOdrl(rules);
+ }
+};
+
+/**
+ * Convert governance configuration to ODRL format
+ *
+ * @param config - The governance configuration from environment variables
+ * @returns Array of ODRL policies
+ */
+/**
+ * Convert governance rule(s) to ODRL format
+ * - Single rule -> single OdrlRule object
+ * - Multiple rules -> array of OdrlRule objects
+ */
+
+/**
+ * Convert DTR policies from environment config to ODRL format with all possible constraint orderings.
+ *
+ * For each policy, this generates separate complete policies for each possible ordering of constraints.
+ * This ensures DTR matching succeeds regardless of constraint order by providing all permutations
+ * as separate policy entries in the array.
+ *
+ * Uses caching to avoid recalculating permutations on every request.
+ */
+const convertDtrPoliciesToOdrl = async (): Promise => {
+ return await getCachedDtrGovernancePolicies();
+};
+
+/**
+ * Get governance policy based on semantic ID from environment configuration
+ */
+const getGovernancePolicyForSemanticId = async (semanticId: string): Promise => {
+ return await getCachedGovernancePolicies(semanticId);
+};
+
+/**
+ * Fetch a specific submodel data with request cancellation and concurrency control
+ */
+export const fetchSubmodel = async (
+ counterPartyId: string,
+ shellId: string,
+ submodelId: string,
+ semanticId?: string
+): Promise => {
+ // Get governance policies:
+ // - If semanticId provided: try to find specific config, fallback to default if not found
+ // - If no semanticId: use default policies
+ const governance = semanticId ?
+ await getGovernancePolicyForSemanticId(semanticId) :
+ await getCachedDefaultGovernancePolicies();
+
+ const dtrPolicies = await convertDtrPoliciesToOdrl();
+
+ // Debug logging to check governance policies
+ console.log('Submodel request governance policies:', {
+ semanticId,
+ governanceCount: governance?.length || 0,
+ governance: governance
+ });
+
+ const request: SubmodelDiscoveryRequest = {
+ counterPartyId,
+ id: shellId,
+ submodelId,
+ dtrGovernance: dtrPolicies,
+ governance
+ };
+
+ const response = await axios.post(
+ `${backendUrl}/discover/shell/submodel`,
+ request
+ );
+
+ return response.data;
+};
diff --git a/ichub-frontend/src/features/part-discovery/components/FilterChips.tsx b/ichub-frontend/src/features/part-discovery/components/FilterChips.tsx
new file mode 100644
index 00000000..7414147d
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/FilterChips.tsx
@@ -0,0 +1,122 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import React from 'react';
+import { Box, Chip, Typography } from '@mui/material';
+import { SearchFilters, PartType } from '../types';
+
+interface FilterChipsProps {
+ filters: SearchFilters;
+ partType: PartType;
+}
+
+export const FilterChips: React.FC = ({ filters, partType }) => {
+ const getActiveFilterChips = () => {
+ const filterList = [
+ {
+ value: filters.customerPartId,
+ label: 'Customer Part ID',
+ tooltip: 'Customer Part ID'
+ },
+ {
+ value: filters.manufacturerPartId,
+ label: 'Manufacturer Part ID',
+ tooltip: 'Manufacturer Part ID'
+ },
+ {
+ value: filters.globalAssetId,
+ label: 'Global Asset ID',
+ tooltip: 'Global Asset ID'
+ },
+ // Only show Part Instance ID filter when Part Instance is selected
+ ...(partType === 'Serialized' ? [{
+ value: filters.partInstanceId,
+ label: 'Part Instance ID',
+ tooltip: 'Part Instance Identifier'
+ }] : [])
+ ];
+
+ return filterList
+ .filter(filter => filter.value && filter.value.trim())
+ .map((filter, index) => (
+
+
+ {filter.label}:
+
+
+ {filter.value}
+
+
+ }
+ size="medium"
+ color="primary"
+ variant="filled"
+ title={`${filter.tooltip}: ${filter.value}`}
+ sx={{
+ backgroundColor: 'rgba(25, 118, 210, 0.1)',
+ color: '#1976d2',
+ border: '1px solid rgba(25, 118, 210, 0.3)',
+ borderRadius: '20px',
+ fontSize: '0.85rem',
+ fontWeight: '500',
+ px: 2,
+ py: 0.5,
+ height: 'auto',
+ minHeight: '32px',
+ maxWidth: '100%',
+ '& .MuiChip-label': {
+ px: 1,
+ py: 0.5,
+ whiteSpace: 'nowrap',
+ overflow: 'visible',
+ textOverflow: 'unset'
+ },
+ '&:hover': {
+ backgroundColor: 'rgba(25, 118, 210, 0.15)',
+ borderColor: 'rgba(25, 118, 210, 0.5)',
+ transform: 'translateY(-1px)',
+ boxShadow: '0 4px 12px rgba(25, 118, 210, 0.2)'
+ },
+ transition: 'all 0.2s ease-in-out'
+ }}
+ />
+ ));
+ };
+
+ const chips = getActiveFilterChips();
+
+ if (chips.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {chips}
+
+
+ );
+};
diff --git a/ichub-frontend/src/features/part-discovery/components/PaginationControls.tsx b/ichub-frontend/src/features/part-discovery/components/PaginationControls.tsx
new file mode 100644
index 00000000..a4c6fda5
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/PaginationControls.tsx
@@ -0,0 +1,159 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import React from 'react';
+import { Box, Button, Typography, CircularProgress } from '@mui/material';
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
+import { ShellDiscoveryPaginator } from '../api';
+
+interface PaginationControlsProps {
+ paginator: ShellDiscoveryPaginator | null;
+ currentPage: number;
+ totalPages: number;
+ pageLimit: number;
+ isLoading: boolean;
+ isLoadingNext: boolean;
+ isLoadingPrevious: boolean;
+ onPageChange: (page: number) => void;
+}
+
+export const PaginationControls: React.FC = ({
+ paginator,
+ currentPage,
+ totalPages,
+ pageLimit,
+ isLoading,
+ isLoadingNext,
+ isLoadingPrevious,
+ onPageChange
+}) => {
+ if (!paginator || isLoading || pageLimit === 0) {
+ return null;
+ }
+
+ return (
+
+ {paginator.hasPrevious() && (
+
+ )}
+
+
+
+ Page {currentPage}
+
+ {totalPages > 1 && (
+
+ of {totalPages}
+
+ )}
+
+
+ {paginator.hasNext() && (
+
+ )}
+
+ );
+};
diff --git a/ichub-frontend/src/features/part-discovery/components/PartnerSearch.tsx b/ichub-frontend/src/features/part-discovery/components/PartnerSearch.tsx
new file mode 100644
index 00000000..4db5f275
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/PartnerSearch.tsx
@@ -0,0 +1,143 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import React from 'react';
+import { TextField, Button, Autocomplete, CircularProgress } from '@mui/material';
+import SearchIcon from '@mui/icons-material/Search';
+import { PartnerInstance } from '../../../types/partner';
+
+interface PartnerSearchProps {
+ bpnl: string;
+ onBpnlChange: (value: string) => void;
+ selectedPartner: PartnerInstance | null;
+ onSelectedPartnerChange: (partner: PartnerInstance | null) => void;
+ availablePartners: PartnerInstance[];
+ isLoadingPartners: boolean;
+ onSearch: () => void;
+ isLoading: boolean;
+}
+
+export const PartnerSearch: React.FC = ({
+ bpnl,
+ onBpnlChange,
+ selectedPartner,
+ onSelectedPartnerChange,
+ availablePartners,
+ isLoadingPartners,
+ onSearch,
+ isLoading
+}) => {
+ return (
+ <>
+ {
+ onSelectedPartnerChange(newValue);
+ if (newValue) {
+ onBpnlChange(newValue.bpnl);
+ }
+ }}
+ inputValue={bpnl}
+ onInputChange={(_, newInputValue) => {
+ onBpnlChange(newInputValue);
+ if (!newInputValue) {
+ onSelectedPartnerChange(null);
+ }
+ }}
+ options={availablePartners}
+ getOptionLabel={(option) => option.bpnl}
+ loading={isLoadingPartners}
+ renderOption={(props, option) => (
+
+
+
{option.name}
+
{option.bpnl}
+
+
+ )}
+ renderInput={(params) => (
+
+ {isLoadingPartners ? : null}
+ {params.InputProps.endAdornment}
+ >
+ ),
+ }}
+ sx={{
+ '& .MuiOutlinedInput-root': {
+ backgroundColor: 'rgba(255, 255, 255, 0.95)',
+ borderRadius: 3,
+ '&:hover': {
+ backgroundColor: 'rgba(255, 255, 255, 1)',
+ },
+ '&.Mui-focused': {
+ backgroundColor: 'rgba(255, 255, 255, 1)',
+ boxShadow: '0 0 0 3px rgba(25, 118, 210, 0.1)',
+ }
+ }
+ }}
+ />
+ )}
+ sx={{ mb: 3 }}
+ />
+
+ : }
+ size="large"
+ fullWidth
+ sx={{
+ py: 1.5,
+ borderRadius: 3,
+ fontSize: '1rem',
+ fontWeight: '600',
+ textTransform: 'none',
+ background: 'linear-gradient(45deg, #1976d2 30%, #42a5f5 90%)',
+ boxShadow: '0 8px 32px rgba(25, 118, 210, 0.3)',
+ '&:hover': {
+ background: 'linear-gradient(45deg, #1565c0 30%, #1976d2 90%)',
+ boxShadow: '0 12px 40px rgba(25, 118, 210, 0.4)',
+ transform: 'translateY(-2px)'
+ },
+ '&:disabled': {
+ background: 'rgba(0, 0, 0, 0.12)',
+ boxShadow: 'none',
+ transform: 'none'
+ },
+ transition: 'all 0.3s ease'
+ }}
+ >
+ Start Discovery
+
+ >
+ );
+};
diff --git a/ichub-frontend/src/features/part-discovery/components/PartsDiscoverySidebar.tsx b/ichub-frontend/src/features/part-discovery/components/PartsDiscoverySidebar.tsx
new file mode 100644
index 00000000..807a5791
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/PartsDiscoverySidebar.tsx
@@ -0,0 +1,311 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import React from 'react';
+import {
+ Box,
+ Typography,
+ Radio,
+ RadioGroup,
+ FormControlLabel,
+ TextField,
+ Select,
+ MenuItem,
+ FormControl,
+ InputLabel,
+ Chip
+} from '@mui/material';
+
+interface PartsDiscoverySidebarProps {
+ partType: string;
+ onPartTypeChange: (event: React.ChangeEvent) => void;
+ pageLimit: number;
+ onPageLimitChange: (limit: number) => void;
+ customLimit: string;
+ onCustomLimitChange: (limit: string) => void;
+ isCustomLimit: boolean;
+ onIsCustomLimitChange: (isCustom: boolean) => void;
+ customerPartId: string;
+ onCustomerPartIdChange: (id: string) => void;
+ manufacturerPartId: string;
+ onManufacturerPartIdChange: (id: string) => void;
+ globalAssetId: string;
+ onGlobalAssetIdChange: (id: string) => void;
+ partInstanceId: string;
+ onPartInstanceIdChange: (id: string) => void;
+}
+
+const PartsDiscoverySidebar: React.FC = ({
+ partType,
+ onPartTypeChange,
+ pageLimit,
+ onPageLimitChange,
+ customLimit,
+ onCustomLimitChange,
+ isCustomLimit,
+ onIsCustomLimitChange,
+ customerPartId,
+ onCustomerPartIdChange,
+ manufacturerPartId,
+ onManufacturerPartIdChange,
+ globalAssetId,
+ onGlobalAssetIdChange,
+ partInstanceId,
+ onPartInstanceIdChange
+}) => {
+ const sidebarStyles = {
+ textField: {
+ '& .MuiOutlinedInput-root': {
+ backgroundColor: 'rgba(255, 255, 255, 0.08)',
+ borderRadius: 2,
+ border: '1px solid rgba(255, 255, 255, 0.2)',
+ color: 'white',
+ '& input::placeholder': {
+ fontSize: '0.75rem',
+ color: 'rgba(255, 255, 255, 0.5)'
+ },
+ '&:hover': {
+ backgroundColor: 'rgba(255, 255, 255, 0.12)',
+ borderColor: 'rgba(255, 255, 255, 0.3)'
+ },
+ '&.Mui-focused': {
+ backgroundColor: 'rgba(255, 255, 255, 0.15)',
+ borderColor: '#60a5fa'
+ }
+ },
+ '& .MuiInputLabel-root': {
+ color: 'rgba(255, 255, 255, 0.8)',
+ fontSize: '0.875rem',
+ '&.Mui-focused': {
+ color: '#bfdbfe'
+ }
+ },
+ '& .MuiFormHelperText-root': {
+ color: 'rgba(255, 255, 255, 0.7)',
+ fontSize: '0.75rem'
+ }
+ },
+ sectionTitle: {
+ fontWeight: '600',
+ color: 'white',
+ mb: 2,
+ fontSize: '0.85rem',
+ textTransform: 'uppercase' as const,
+ letterSpacing: '0.3px',
+ borderBottom: '1px solid rgba(255, 255, 255, 0.15)',
+ pb: 1
+ }
+ };
+
+ return (
+ <>
+ {/* Digital Twin Type Section */}
+
+
+ Digital Twin Type
+
+
+ } label="Part Type (Catalog)" />
+ } label="Part Instance (Serialized)" />
+
+
+
+ {/* Results per Page Section */}
+
+
+ Results per Page
+
+
+ Results per Page
+
+
+
+ {isCustomLimit && (
+
+ onCustomLimitChange(e.target.value)}
+ inputProps={{ min: 1, max: 1000 }}
+ helperText={
+ customLimit && (isNaN(parseInt(customLimit)) || parseInt(customLimit) < 1 || parseInt(customLimit) > 1000)
+ ? "Please enter a valid number between 1 and 1000"
+ : "Enter a number between 1 and 1000"
+ }
+ error={customLimit !== '' && (isNaN(parseInt(customLimit)) || parseInt(customLimit) < 1 || parseInt(customLimit) > 1000)}
+ sx={sidebarStyles.textField}
+ />
+
+
+
+ Quick select:
+
+ {[25, 75, 150, 200, 500].map((value) => (
+ {
+ onCustomLimitChange(value.toString());
+ onPageLimitChange(value);
+ }}
+ sx={{
+ cursor: 'pointer',
+ backgroundColor: 'rgba(255, 255, 255, 0.1)',
+ color: 'white',
+ border: '1px solid rgba(255, 255, 255, 0.3)',
+ fontSize: '0.7rem',
+ height: '24px',
+ '&:hover': {
+ backgroundColor: 'rgba(255, 255, 255, 0.2)',
+ borderColor: '#60a5fa'
+ }
+ }}
+ />
+ ))}
+
+
+ )}
+
+
+ {/* Advanced Options Section */}
+
+
+ Advanced Options
+
+
+ onCustomerPartIdChange(e.target.value)}
+ sx={{ mb: 2, ...sidebarStyles.textField }}
+ helperText="Search by specific Customer Part identifier"
+ />
+
+ onManufacturerPartIdChange(e.target.value)}
+ sx={{ mb: 2, ...sidebarStyles.textField }}
+ helperText="Search by specific Manufacturer Part identifier"
+ />
+
+ onGlobalAssetIdChange(e.target.value)}
+ sx={{ mb: 2, ...sidebarStyles.textField }}
+ helperText="Global Asset ID of the Digital Twin"
+ />
+
+ {/* Part Instance ID - Only shown when Part Instance is selected */}
+ {partType === 'Serialized' && (
+ onPartInstanceIdChange(e.target.value)}
+ sx={{ mb: 2, ...sidebarStyles.textField }}
+ helperText="Search by specific Part Instance identifier"
+ />
+ )}
+
+ >
+ );
+};
+
+export default PartsDiscoverySidebar;
diff --git a/ichub-frontend/src/features/part-discovery/components/SearchHeader.tsx b/ichub-frontend/src/features/part-discovery/components/SearchHeader.tsx
new file mode 100644
index 00000000..070a4d74
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/SearchHeader.tsx
@@ -0,0 +1,113 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import React from 'react';
+import { Box, Grid2, Button, Typography } from '@mui/material';
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import { FilterChips } from './FilterChips';
+import { SearchFilters, PartType } from '../types';
+
+interface SearchHeaderProps {
+ bpnl: string;
+ companyName: string;
+ filters: SearchFilters;
+ partType: PartType;
+ onGoBack: () => void;
+}
+
+export const SearchHeader: React.FC = ({
+ bpnl,
+ companyName,
+ filters,
+ partType,
+ onGoBack
+}) => {
+ return (
+
+
+
+ }
+ size="small"
+ sx={{
+ borderColor: 'primary.main',
+ color: 'primary.main',
+ '&:hover': {
+ backgroundColor: 'primary.main',
+ color: 'white',
+ borderColor: 'primary.main'
+ }
+ }}
+ >
+ New Search
+
+
+
+
+ Dataspace Discovery
+
+
+
+
+
+
+
+ {companyName}
+
+
+ {bpnl}
+
+
+
+
+
+
+ );
+};
diff --git a/ichub-frontend/src/features/part-discovery/components/SearchModeToggle.tsx b/ichub-frontend/src/features/part-discovery/components/SearchModeToggle.tsx
new file mode 100644
index 00000000..5f694948
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/SearchModeToggle.tsx
@@ -0,0 +1,159 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import React from 'react';
+import { Box, Button, Chip } from '@mui/material';
+import VisibilityIcon from '@mui/icons-material/Visibility';
+import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
+import { SearchMode } from '../types';
+
+interface SearchModeToggleProps {
+ searchMode: SearchMode;
+ onSearchModeChange: (mode: SearchMode) => void;
+ isVisible: boolean;
+ onDisplayFilters: () => void;
+ onHideFilters: () => void;
+}
+
+export const SearchModeToggle: React.FC = ({
+ searchMode,
+ onSearchModeChange,
+ isVisible,
+ onDisplayFilters,
+ onHideFilters
+}) => {
+ return (
+
+ {/* Display Filters Button - Only show in Discovery mode when sidebar should be available but is hidden */}
+ {searchMode === 'discovery' && !isVisible && (
+ }
+ sx={{
+ color: 'rgba(25, 118, 210, 0.8)',
+ fontSize: '0.8rem',
+ textTransform: 'none',
+ fontWeight: 500,
+ py: 0.3,
+ px: 0.8,
+ minHeight: '22px',
+ '&:hover': {
+ backgroundColor: 'rgba(25, 118, 210, 0.08)',
+ color: '#1976d2'
+ },
+ '& .MuiButton-startIcon': {
+ marginRight: '4px',
+ '& > svg': {
+ fontSize: '14px'
+ }
+ }
+ }}
+ >
+ Display Filters
+
+ )}
+
+ {/* Hide Filters Button - Only show in Discovery mode when sidebar is visible */}
+ {searchMode === 'discovery' && isVisible && (
+ }
+ sx={{
+ color: 'rgba(25, 118, 210, 0.8)',
+ fontSize: '0.8rem',
+ textTransform: 'none',
+ fontWeight: 500,
+ py: 0.3,
+ px: 0.8,
+ minHeight: '22px',
+ '&:hover': {
+ backgroundColor: 'rgba(25, 118, 210, 0.08)',
+ color: '#1976d2'
+ },
+ '& .MuiButton-startIcon': {
+ marginRight: '4px',
+ '& > svg': {
+ fontSize: '14px'
+ }
+ }
+ }}
+ >
+ Hide Filters
+
+ )}
+
+ {/* Search Mode Toggle */}
+
+ onSearchModeChange('discovery')}
+ color={searchMode === 'discovery' ? 'primary' : 'default'}
+ size="small"
+ sx={{
+ fontSize: '0.75rem',
+ fontWeight: 500,
+ transition: 'all 0.2s ease-in-out',
+ '&:hover': {
+ transform: 'translateY(-1px)',
+ boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
+ }
+ }}
+ />
+ onSearchModeChange('single')}
+ color={searchMode === 'single' ? 'primary' : 'default'}
+ size="small"
+ sx={{
+ fontSize: '0.75rem',
+ fontWeight: 500,
+ transition: 'all 0.2s ease-in-out',
+ '&:hover': {
+ transform: 'translateY(-1px)',
+ boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
+ }
+ }}
+ />
+
+
+ );
+};
diff --git a/ichub-frontend/src/features/part-discovery/components/SerializedPartsTable.tsx b/ichub-frontend/src/features/part-discovery/components/SerializedPartsTable.tsx
new file mode 100644
index 00000000..12073935
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/SerializedPartsTable.tsx
@@ -0,0 +1,390 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import { useState } from 'react';
+import {
+ Box,
+ Typography,
+ Chip,
+ Tooltip
+} from '@mui/material';
+import { DataGrid, GridColDef, GridActionsCellItem } from '@mui/x-data-grid';
+import ContentCopy from '@mui/icons-material/ContentCopy';
+import Download from '@mui/icons-material/Download';
+import CheckCircle from '@mui/icons-material/CheckCircle';
+import VisibilityIcon from '@mui/icons-material/Visibility';
+import { SerializedPartData } from '../types';
+
+interface SerializedPartsTableProps {
+ parts: SerializedPartData[];
+ onView?: (part: SerializedPartData) => void;
+}
+
+const SerializedPartsTable = ({ parts, onView }: SerializedPartsTableProps) => {
+ const [copySuccess, setCopySuccess] = useState(null);
+
+ const handleCopyAasId = async (aasId: string, partId: string) => {
+ try {
+ await navigator.clipboard.writeText(aasId);
+ console.log('AAS ID copied to clipboard:', aasId);
+ setCopySuccess(partId);
+
+ // Reset after 2 seconds
+ setTimeout(() => {
+ setCopySuccess(null);
+ }, 2000);
+ } catch (err) {
+ console.error('Failed to copy AAS ID:', err);
+ }
+ };
+
+ const handleDownloadTwinData = (part: SerializedPartData) => {
+ if (part.rawTwinData) {
+ try {
+ const jsonString = JSON.stringify(part.rawTwinData, null, 2);
+ const blob = new Blob([jsonString], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `twin-${part.manufacturerPartId || part.aasId || 'data'}.json`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+
+ console.log('Twin data downloaded successfully');
+ } catch (err) {
+ console.error('Failed to download twin data:', err);
+ }
+ }
+ };
+
+ // Transform data for DataGrid
+ const rows = parts.map((part, index) => ({
+ id: part.id || `part-${index}`,
+ dtrIndex: part.dtrIndex,
+ globalAssetId: part.globalAssetId,
+ aasId: part.aasId,
+ idShort: part.idShort,
+ manufacturerId: part.manufacturerId,
+ manufacturerPartId: part.manufacturerPartId,
+ customerPartId: part.customerPartId || '',
+ partInstanceId: part.partInstanceId || '',
+ digitalTwinType: part.digitalTwinType,
+ submodelCount: part.submodelCount,
+ rawTwinData: part.rawTwinData
+ }));
+
+ // Define columns for DataGrid
+ const columns: GridColDef[] = [
+ {
+ field: 'dtrIndex',
+ headerName: 'DTR Source',
+ width: 100,
+ align: 'center',
+ headerAlign: 'center',
+ renderCell: (params) => (
+
+ ),
+ },
+ {
+ field: 'globalAssetId',
+ headerName: 'Global Asset ID',
+ width: 280,
+ renderCell: (params) => (
+
+
+ {params.value}
+
+
+ ),
+ },
+ {
+ field: 'aasId',
+ headerName: 'AAS ID',
+ width: 280,
+ renderCell: (params) => (
+
+
+
+ {params.value}
+
+
+
+ ),
+ },
+ {
+ field: 'idShort',
+ headerName: 'ID Short',
+ width: 180,
+ renderCell: (params) => (
+
+
+ {params.value || '-'}
+
+
+ ),
+ },
+ {
+ field: 'manufacturerId',
+ headerName: 'Manufacturer ID',
+ width: 150,
+ renderCell: (params) => (
+
+
+ {params.value}
+
+
+ ),
+ },
+ {
+ field: 'manufacturerPartId',
+ headerName: 'Manufacturer Part ID',
+ width: 180,
+ renderCell: (params) => (
+
+
+ {params.value}
+
+
+ ),
+ },
+ {
+ field: 'customerPartId',
+ headerName: 'Customer Part ID',
+ width: 150,
+ renderCell: (params) => (
+
+
+ {params.value || '-'}
+
+
+ ),
+ },
+ {
+ field: 'partInstanceId',
+ headerName: 'Part Instance ID',
+ width: 150,
+ renderCell: (params) => (
+
+
+ {params.value || '-'}
+
+
+ ),
+ },
+ {
+ field: 'digitalTwinType',
+ headerName: 'Digital Twin Type',
+ width: 150,
+ align: 'center',
+ headerAlign: 'center',
+ renderCell: (params) => (
+
+ ),
+ },
+ {
+ field: 'submodelCount',
+ headerName: 'Submodels',
+ width: 100,
+ align: 'center',
+ headerAlign: 'center',
+ renderCell: (params) => (
+
+ {params.value}
+
+ ),
+ },
+ {
+ field: 'actions',
+ type: 'actions',
+ headerName: 'Actions',
+ width: 120,
+ getActions: (params) => [
+
+ {copySuccess === params.row.id ? (
+
+ ) : (
+
+ )}
+
+ }
+ label="Copy AAS ID"
+ onClick={() => handleCopyAasId(params.row.aasId, params.row.id)}
+ />,
+
+
+
+ }
+ label="Download"
+ onClick={() => handleDownloadTwinData(params.row)}
+ disabled={!params.row.rawTwinData}
+ />,
+
+
+
+ }
+ label="View"
+ onClick={() => {
+ console.log('View details for:', params.row);
+ if (onView) {
+ onView(params.row);
+ }
+ }}
+ />,
+ ],
+ },
+ ];
+
+ return (
+
+ (
+
+
+ Showing {parts.length} serialized parts
+
+
+ Click on column headers to sort • Use column filters for search
+
+
+ ),
+ }}
+ />
+
+ );
+};
+
+export default SerializedPartsTable;
diff --git a/ichub-frontend/src/features/part-discovery/components/SingleTwinResult.tsx b/ichub-frontend/src/features/part-discovery/components/SingleTwinResult.tsx
new file mode 100644
index 00000000..11ff7a7c
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/SingleTwinResult.tsx
@@ -0,0 +1,1507 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import React, { useState, useEffect } from 'react';
+import {
+ Box,
+ Typography,
+ Card,
+ Chip,
+ Button,
+ IconButton,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Tooltip,
+ Snackbar,
+ useMediaQuery,
+ useTheme
+} from '@mui/material';
+import InfoIcon from '@mui/icons-material/Info';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import FingerprintIcon from '@mui/icons-material/Fingerprint';
+import PublicIcon from '@mui/icons-material/Public';
+import LabelIcon from '@mui/icons-material/Label';
+import CloseIcon from '@mui/icons-material/Close';
+import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos';
+import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
+import SecurityIcon from '@mui/icons-material/Security';
+import LockIcon from '@mui/icons-material/Lock';
+import { SubmodelViewer } from './SubmodelViewer';
+
+interface SingleTwinResultProps {
+ counterPartyId: string;
+ singleTwinResult: {
+ shell_descriptor: {
+ id: string;
+ idShort?: string;
+ globalAssetId: string;
+ submodelDescriptors: Array<{
+ endpoints: Array<{
+ interface: string;
+ protocolInformation: {
+ href: string;
+ endpointProtocol: string;
+ endpointProtocolVersion: string[];
+ subprotocol: string;
+ subprotocolBody: string;
+ subprotocolBodyEncoding: string;
+ securityAttributes: Array<{
+ type: string;
+ key: string;
+ value: string;
+ }>;
+ };
+ }>;
+ idShort: string;
+ id: string;
+ semanticId: {
+ type: string;
+ keys: Array<{
+ type: string;
+ value: string;
+ }>;
+ };
+ supplementalSemanticId: unknown[];
+ description: unknown[];
+ displayName: unknown[];
+ }>;
+ specificAssetIds: Array<{
+ name: string;
+ value: string;
+ }>;
+ };
+ dtr?: {
+ connectorUrl: string;
+ assetId: string;
+ };
+ };
+}
+
+export const SingleTwinResult: React.FC = ({ counterPartyId, singleTwinResult }) => {
+ const [dtrInfoOpen, setDtrInfoOpen] = useState(false);
+ const [copySuccess, setCopySuccess] = useState(false);
+ const [carouselIndex, setCarouselIndex] = useState(0);
+ const [allSubmodelsOpen, setAllSubmodelsOpen] = useState(false);
+ const [submodelViewerOpen, setSubmodelViewerOpen] = useState(false);
+ const [selectedSubmodel, setSelectedSubmodel] = useState(null);
+
+ const theme = useTheme();
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'));
+
+ // Calculate items per slide for carousel - show fewer items to enable navigation
+ const itemsPerSlide = isMobile ? 2 : 3;
+
+ // Parse semantic ID to extract version and model name
+ const parseSemanticId = (semanticId: string) => {
+ try {
+ // Handle different URN formats:
+ // urn:bamm:io.catenax.single_level_bom_as_built:3.0.0#SingleLevelBomAsBuilt
+ // urn:samm:io.catenax.generic.digital_product_passport:5.0.0#DigitalProductPassport
+
+ const parts = semanticId.split(':');
+ if (parts.length >= 4) {
+ const lastPart = parts[parts.length - 1]; // "3.0.0#SingleLevelBomAsBuilt"
+ const [version, modelName] = lastPart.split('#');
+
+ // Extract model name from the namespace if no # separator
+ let displayName = modelName || '';
+ if (!displayName && parts.length >= 3) {
+ const namespacePart = parts[parts.length - 2]; // "io.catenax.single_level_bom_as_built"
+ const nameParts = namespacePart.split('.');
+ displayName = nameParts[nameParts.length - 1]
+ .split('_')
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' ');
+ }
+
+ return {
+ version: version || 'Unknown',
+ name: displayName || 'Unknown Model',
+ namespace: parts.slice(2, -1).join(':')
+ };
+ }
+
+ return {
+ version: 'Unknown',
+ name: 'Unknown Model',
+ namespace: semanticId
+ };
+ } catch (error) {
+ console.warn('Error parsing semantic ID:', error);
+ return {
+ version: 'Unknown',
+ name: 'Unknown Model',
+ namespace: semanticId
+ };
+ }
+ };
+
+ // Detect and parse verifiable credentials from semantic IDs
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const parseVerifiableCredential = (submodel: any) => {
+ if (!submodel.semanticId?.keys) return null;
+
+ const keys = submodel.semanticId.keys;
+
+ // Look for verifiable credential patterns
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const w3cKey = keys.find((key: any) => key.value?.includes('w3c.github.io/vc-jws') || key.value?.includes('www.w3.org/ns/credentials'));
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const submodelKey = keys.find((key: any) => key.type === 'Submodel');
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const dataElementKey = keys.find((key: any) => key.type === 'DataElement');
+ // Look for additional credential-related keys
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const operationKeys = keys.filter((key: any) => key.type === 'Operation');
+
+ if (w3cKey || dataElementKey) {
+ // This is a verifiable credential
+ let modelName = 'Verifiable Credential';
+ let version = 'Unknown';
+ let credentialType = 'Unknown';
+ let signatureType = null;
+ let w3cVersion = 'Unknown';
+
+ // Extract model name and version from submodel key
+ if (submodelKey?.value) {
+ const parsed = parseSemanticId(submodelKey.value);
+ modelName = parsed.name;
+ version = parsed.version;
+ }
+
+ // Extract credential type from data element
+ if (dataElementKey?.value) {
+ const parts = dataElementKey.value.split('#');
+ if (parts.length > 1) {
+ credentialType = parts[1];
+ } else {
+ const urlParts = dataElementKey.value.split(':');
+ credentialType = urlParts[urlParts.length - 1];
+ }
+ }
+
+ // Extract signature type from operation keys and other sources
+ if (operationKeys.length > 0) {
+ for (const opKey of operationKeys) {
+ if (opKey.value) {
+ const parts = opKey.value.split('#');
+ if (parts.length > 1) {
+ const candidate = parts[1];
+ // Look for signature-related terms
+ if (candidate.toLowerCase().includes('signature') || candidate.includes('JsonWeb') || candidate.includes('Jws')) {
+ signatureType = candidate;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // If not found in operation keys, look in all keys for signature types
+ if (!signatureType) {
+ for (const key of keys) {
+ if (key.value) {
+ // Check for JsonWebSignature patterns
+ if (key.value.includes('JsonWebSignature2020') || key.value.includes('JsonWebSignature')) {
+ if (key.value.includes('JsonWebSignature2020')) {
+ signatureType = 'JWS 2020';
+ } else {
+ signatureType = 'JsonWebSignature';
+ }
+ break;
+ }
+ // Check for other signature patterns
+ const parts = key.value.split('#');
+ if (parts.length > 1) {
+ const candidate = parts[1];
+ if (candidate.toLowerCase().includes('signature') || candidate.includes('JsonWeb') || candidate.includes('Jws')) {
+ // Format common signature types nicely
+ if (candidate.includes('JsonWebSignature2020')) {
+ signatureType = 'JWS 2020';
+ } else if (candidate.includes('JsonWebSignature')) {
+ signatureType = 'JsonWebSignature';
+ } else {
+ signatureType = candidate;
+ }
+ break;
+ }
+ }
+ // Also check the URL path for signature type
+ if (key.value.includes('/vc-jws-')) {
+ signatureType = 'JWS 2020';
+ break;
+ }
+ }
+ }
+ }
+
+ // Extract W3C credential version from URL and fallback signature type
+ if (w3cKey?.value) {
+ const w3cUrl = w3cKey.value;
+ // Handle patterns like: https://www.w3.org/ns/credentials/v2 or https://www.w3.org/ns/credentials/v1.1
+ if (w3cUrl.includes('www.w3.org/ns/credentials/')) {
+ const versionMatch = w3cUrl.match(/\/v(\d+(?:\.\d+)?)/);
+ if (versionMatch) {
+ w3cVersion = versionMatch[1]; // Extract just the version number (e.g., "2" or "1.1")
+ }
+ } else if (w3cUrl.includes('w3c.github.io/vc-jws')) {
+ // Handle older GitHub format - try to extract version from path
+ const versionMatch = w3cUrl.match(/-(\d{4})/); // Look for year-based versions
+ if (versionMatch) {
+ w3cVersion = versionMatch[1];
+ } else {
+ w3cVersion = '2020'; // Default for vc-jws format
+ }
+
+ // If signature type not found elsewhere, infer from vc-jws URL
+ if (!signatureType) {
+ signatureType = 'JWS 2020';
+ }
+ }
+ }
+
+ return {
+ isVerifiable: true,
+ modelName,
+ version,
+ credentialType,
+ signatureType,
+ w3cVersion,
+ w3cUrl: w3cKey?.value,
+ submodelUrl: submodelKey?.value,
+ dataElementUrl: dataElementKey?.value
+ };
+ }
+
+ return null;
+ };
+
+ // Reset carousel when submodels change
+ useEffect(() => {
+ setCarouselIndex(0);
+ }, [singleTwinResult.shell_descriptor.submodelDescriptors.length]);
+
+ // Handle carousel navigation
+ const handlePrevious = () => {
+ setCarouselIndex(prev => Math.max(0, prev - itemsPerSlide));
+ };
+
+ const handleNext = () => {
+ const maxIndex = Math.max(0, singleTwinResult.shell_descriptor.submodelDescriptors.length - itemsPerSlide);
+ setCarouselIndex(prev => Math.min(maxIndex, prev + itemsPerSlide));
+ };
+
+ const handleCopyToClipboard = (text: string) => {
+ navigator.clipboard.writeText(text).then(() => {
+ setCopySuccess(true);
+ setTimeout(() => setCopySuccess(false), 2000);
+ });
+ };
+
+ // Extract digitalTwinType from specificAssetIds
+ const digitalTwinType = singleTwinResult.shell_descriptor.specificAssetIds.find(
+ assetId => assetId.name === 'digitalTwinType'
+ )?.value || 'Asset Administration Shell';
+
+ const handleViewSubmodels = () => {
+ setAllSubmodelsOpen(true);
+ };
+
+ const handleRetrieveSubmodel = (submodel: SingleTwinResultProps['singleTwinResult']['shell_descriptor']['submodelDescriptors'][0]) => {
+ setSelectedSubmodel(submodel);
+ setSubmodelViewerOpen(true);
+ };
+
+ return (
+
+ {/* Single Twin Results Header */}
+
+ {/* Display idShort if available, otherwise display nothing */}
+ {singleTwinResult.shell_descriptor.idShort && (
+
+ {singleTwinResult.shell_descriptor.idShort}
+
+ )}
+
+ {/* DTR Information Button */}
+
+
+ DTR Details
+
+ setDtrInfoOpen(true)}
+ sx={{
+ color: 'primary.main',
+ backgroundColor: 'rgba(25, 118, 210, 0.08)',
+ '&:hover': {
+ backgroundColor: 'rgba(25, 118, 210, 0.16)'
+ }
+ }}
+ >
+
+
+
+
+
+ {/* Cards Row - Digital Twin Info and IDs */}
+
+ {/* Digital Twin Information Card */}
+
+
+
+
+ Digital Twin Information
+
+
+
+
+ {/* Description if available */}
+ {(() => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const descriptor = singleTwinResult.shell_descriptor as any;
+ if (descriptor.description && Array.isArray(descriptor.description) && descriptor.description.length > 0) {
+ return (
+
+
+ Description:
+
+
+ {descriptor.description[0].text}
+ {descriptor.description[0].language && (
+
+ ({descriptor.description[0].language})
+
+ )}
+
+
+ );
+ }
+ return null;
+ })()}
+
+ {/* Asset Identifiers Section */}
+ {singleTwinResult.shell_descriptor.specificAssetIds.length > 0 && (
+
+
+
+ Asset Identifiers
+
+
+ {singleTwinResult.shell_descriptor.specificAssetIds.map((assetId, index) => (
+
+ }
+ label={`${assetId.name}: ${assetId.value}`}
+ variant="outlined"
+ size="small"
+ sx={{
+ maxWidth: '400px',
+ '& .MuiChip-label': {
+ fontFamily: 'monospace',
+ fontSize: '0.75rem'
+ },
+ '& .MuiChip-icon': {
+ color: 'success.main'
+ }
+ }}
+ />
+
+ handleCopyToClipboard(assetId.value)}
+ sx={{
+ color: 'text.secondary',
+ '&:hover': {
+ color: 'success.main',
+ backgroundColor: 'rgba(76, 175, 80, 0.1)'
+ }
+ }}
+ >
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+ {/* IDs Card */}
+
+
+
+ Identifiers
+
+
+
+ {/* AAS ID */}
+
+
+ AAS ID:
+
+
+ }
+ label={singleTwinResult.shell_descriptor.id}
+ variant="outlined"
+ size="small"
+ sx={{
+ maxWidth: '100%',
+ '& .MuiChip-label': {
+ fontFamily: 'monospace',
+ fontSize: '0.75rem'
+ },
+ '& .MuiChip-icon': {
+ color: 'primary.main'
+ }
+ }}
+ />
+
+ handleCopyToClipboard(singleTwinResult.shell_descriptor.id)}
+ sx={{
+ color: 'text.secondary',
+ '&:hover': {
+ color: 'primary.main',
+ backgroundColor: 'rgba(25, 118, 210, 0.1)'
+ }
+ }}
+ >
+
+
+
+
+
+
+ {/* Global Asset ID */}
+
+
+ Global Asset ID:
+
+
+ }
+ label={singleTwinResult.shell_descriptor.globalAssetId}
+ variant="outlined"
+ size="small"
+ sx={{
+ maxWidth: '100%',
+ '& .MuiChip-label': {
+ fontFamily: 'monospace',
+ fontSize: '0.75rem'
+ },
+ '& .MuiChip-icon': {
+ color: 'success.main'
+ }
+ }}
+ />
+
+ handleCopyToClipboard(singleTwinResult.shell_descriptor.globalAssetId)}
+ sx={{
+ color: 'text.secondary',
+ '&:hover': {
+ color: 'primary.main',
+ backgroundColor: 'rgba(25, 118, 210, 0.1)'
+ }
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ {/* Submodels Section */}
+
+
+
+
+ Available Submodels
+
+
+ {singleTwinResult.shell_descriptor.submodelDescriptors.length} submodel(s) available
+
+
+ {/* View All Submodels button moved to top right */}
+ {singleTwinResult.shell_descriptor.submodelDescriptors.length > 0 && (
+
+ )}
+
+
+ {singleTwinResult.shell_descriptor.submodelDescriptors.length > 0 && (() => {
+ const totalSubmodels = singleTwinResult.shell_descriptor.submodelDescriptors.length;
+ const endIndex = Math.min(carouselIndex + itemsPerSlide, totalSubmodels);
+ const currentSubmodels = singleTwinResult.shell_descriptor.submodelDescriptors.slice(carouselIndex, endIndex);
+ const canGoPrev = carouselIndex > 0;
+ const canGoNext = endIndex < totalSubmodels;
+
+ return (
+
+ {/* Carousel Container */}
+ itemsPerSlide ? 1.5 : 0
+ }}>
+ {currentSubmodels.map((submodel, index) => {
+ const actualIndex = carouselIndex + index;
+ return (
+
+
+ {/* Header with title and ID in top right */}
+
+
+ {submodel.idShort || `Submodel ${actualIndex + 1}`}
+
+
+
+ {/* Green lock for verifiable credentials with signature type */}
+ {(() => {
+ const verifiableInfo = parseVerifiableCredential(submodel);
+ if (verifiableInfo) {
+ return (
+
+
+
+
+ {verifiableInfo.signatureType && (
+
+
+
+ )}
+
+ );
+ }
+ return null;
+ })()}
+
+ {/* ID in top right with copy button */}
+ {submodel.id && (
+ <>
+
+
+ {submodel.id.split(':').pop()?.substring(0, 8)}...
+
+
+
+ handleCopyToClipboard(submodel.id)}
+ sx={{
+ p: 0.25,
+ color: 'text.secondary',
+ '&:hover': {
+ color: 'primary.main',
+ backgroundColor: 'rgba(25, 118, 210, 0.1)'
+ }
+ }}
+ >
+
+
+
+ >
+ )}
+
+
+
+ {/* Description if available */}
+ {(() => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const submodelAny = submodel as any;
+ if (submodelAny.description && Array.isArray(submodelAny.description) && submodelAny.description.length > 0) {
+ return (
+
+
+ Description:
+
+
+ {submodelAny.description[0].text}
+ {submodelAny.description[0].language && (
+
+ ({submodelAny.description[0].language})
+
+ )}
+
+
+ );
+ }
+ return null;
+ })()}
+
+ {submodel.semanticId && submodel.semanticId.keys && submodel.semanticId.keys.length > 0 && (
+
+ {/* Check if this is a verifiable credential */}
+ {(() => {
+ const verifiableInfo = parseVerifiableCredential(submodel);
+
+ if (verifiableInfo) {
+ // Display verifiable credential information
+ return (
+
+
+
+
+ Verifiable Credential
+
+
+
+
+
+
+
+ Credential Type:
+
+
+
+
+
+
+ );
+ } else {
+ // Display regular semantic ID info
+ const firstKey = submodel.semanticId.keys[0];
+ if (firstKey?.value) {
+ const parsedSemanticId = parseSemanticId(firstKey.value);
+ return (
+
+
+
+ Model:
+
+
+
+
+
+ );
+ }
+ return null;
+ }
+ })()}
+
+ {/* Show additional semantic IDs only if we don't know the model (no chips displayed) */}
+ {(() => {
+ const verifiableInfo = parseVerifiableCredential(submodel);
+ const firstKey = submodel.semanticId.keys[0];
+ const hasKnownModel = verifiableInfo || (firstKey?.value && parseSemanticId(firstKey.value).name !== 'Unknown Model');
+
+ return !hasKnownModel && submodel.semanticId.keys.length > 0 && (
+
+
+ Additional Semantic IDs:
+
+
+ {submodel.semanticId.keys.map((key, keyIndex) => (
+
+
+ {key.value}
+ {key.type && (
+
+ ({key.type})
+
+ )}
+
+
+ ))}
+
+
+ );
+ })()}
+
+ )}
+
+
+ {/* View button at the bottom of each card */}
+
+
+
+
+ );
+ })}
+
+
+ {/* Carousel Indicators and Navigation */}
+ {totalSubmodels > itemsPerSlide && (
+
+ {/* Left Arrow */}
+
+
+
+
+ {/* Indicators */}
+
+ {Array.from({ length: Math.ceil(totalSubmodels / itemsPerSlide) }).map((_, pageIndex) => {
+ const isActive = Math.floor(carouselIndex / itemsPerSlide) === pageIndex;
+ return (
+ setCarouselIndex(pageIndex * itemsPerSlide)}
+ />
+ );
+ })}
+
+ {Math.floor(carouselIndex / itemsPerSlide) + 1} of {Math.ceil(totalSubmodels / itemsPerSlide)} • {totalSubmodels} total
+
+
+
+ {/* Right Arrow */}
+
+
+
+
+ )}
+
+ );
+ })()}
+
+
+ {/* Full-Screen All Submodels Dialog */}
+
+
+ {/* DTR Information Dialog */}
+
+
+ {/* Copy Success Snackbar */}
+ setCopySuccess(false)}
+ message="ID copied to clipboard!"
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
+ sx={{
+ '& .MuiSnackbarContent-root': {
+ backgroundColor: 'success.main',
+ fontSize: '0.875rem'
+ }
+ }}
+ />
+
+ {/* Submodel Viewer Dialog */}
+ {selectedSubmodel && (
+ setSubmodelViewerOpen(false)}
+ counterPartyId={counterPartyId}
+ shellId={singleTwinResult.shell_descriptor.id}
+ dtrConnectorUrl={singleTwinResult.dtr?.connectorUrl}
+ submodel={selectedSubmodel}
+ />
+ )}
+
+ );
+};
diff --git a/ichub-frontend/src/features/part-discovery/components/SingleTwinSearch.tsx b/ichub-frontend/src/features/part-discovery/components/SingleTwinSearch.tsx
new file mode 100644
index 00000000..f268a8df
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/SingleTwinSearch.tsx
@@ -0,0 +1,99 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import React from 'react';
+import { Grid2, TextField, Button, CircularProgress } from '@mui/material';
+import SearchIcon from '@mui/icons-material/Search';
+
+interface SingleTwinSearchProps {
+ singleTwinAasId: string;
+ onSingleTwinAasIdChange: (value: string) => void;
+ onSearch: () => void;
+ isLoading: boolean;
+}
+
+export const SingleTwinSearch: React.FC = ({
+ singleTwinAasId,
+ onSingleTwinAasIdChange,
+ onSearch,
+ isLoading
+}) => {
+ return (
+
+
+ onSingleTwinAasIdChange(e.target.value)}
+ sx={{
+ '& .MuiOutlinedInput-root': {
+ backgroundColor: 'rgba(255, 255, 255, 0.95)',
+ borderRadius: 3,
+ '&:hover': {
+ backgroundColor: 'rgba(255, 255, 255, 1)',
+ },
+ '&.Mui-focused': {
+ backgroundColor: 'rgba(255, 255, 255, 1)',
+ boxShadow: '0 0 0 3px rgba(25, 118, 210, 0.1)',
+ }
+ }
+ }}
+ />
+
+
+ : }
+ size="large"
+ fullWidth
+ sx={{
+ py: 1.5,
+ borderRadius: 3,
+ fontSize: '1rem',
+ fontWeight: '600',
+ textTransform: 'none',
+ background: 'linear-gradient(45deg, #1976d2 30%, #42a5f5 90%)',
+ boxShadow: '0 8px 32px rgba(25, 118, 210, 0.3)',
+ '&:hover': {
+ background: 'linear-gradient(45deg, #1565c0 30%, #1976d2 90%)',
+ boxShadow: '0 12px 40px rgba(25, 118, 210, 0.4)',
+ transform: 'translateY(-2px)'
+ },
+ '&:disabled': {
+ background: 'rgba(0, 0, 0, 0.12)',
+ boxShadow: 'none',
+ transform: 'none'
+ },
+ transition: 'all 0.3s ease'
+ }}
+ >
+ Search Twin
+
+
+
+ );
+};
diff --git a/ichub-frontend/src/features/part-discovery/components/SubmodelViewer.tsx b/ichub-frontend/src/features/part-discovery/components/SubmodelViewer.tsx
new file mode 100644
index 00000000..58f4bc74
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/SubmodelViewer.tsx
@@ -0,0 +1,990 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import axios from 'axios';
+import {
+ Box,
+ Typography,
+ Card,
+ CardContent,
+ Chip,
+ Button,
+ IconButton,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Alert,
+ CircularProgress,
+ Tooltip,
+ Tabs,
+ Tab,
+ useTheme,
+ Paper
+} from '@mui/material';
+import CloseIcon from '@mui/icons-material/Close';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import InfoIcon from '@mui/icons-material/Info';
+import SecurityIcon from '@mui/icons-material/Security';
+import DataObjectIcon from '@mui/icons-material/DataObject';
+import DescriptionIcon from '@mui/icons-material/Description';
+import DownloadIcon from '@mui/icons-material/Download';
+import EmailIcon from '@mui/icons-material/Email';
+import CheckIcon from '@mui/icons-material/Check';
+import RefreshIcon from '@mui/icons-material/Refresh';
+import { fetchSubmodel, SubmodelDiscoveryResponse } from '../api';
+import { submodelAddonRegistry } from './submodel-addons/shared/registry';
+import { usTariffInformationAddon } from './submodel-addons/us-tariff-information/addon';
+
+interface SubmodelViewerProps {
+ open: boolean;
+ onClose: () => void;
+ counterPartyId: string;
+ shellId: string;
+ dtrConnectorUrl?: string;
+ submodel: {
+ id: string;
+ idShort: string;
+ semanticId: {
+ type: string;
+ keys: Array<{
+ type: string;
+ value: string;
+ }>;
+ };
+ };
+}
+
+const JsonViewer: React.FC<{ data: Record; filename?: string }> = ({ data, filename = 'submodel.json' }) => {
+ const [copySuccess, setCopySuccess] = useState(false);
+
+ const handleCopyJson = () => {
+ navigator.clipboard.writeText(JSON.stringify(data, null, 5)).then(() => {
+ setCopySuccess(true);
+ setTimeout(() => setCopySuccess(false), 2000);
+ });
+ };
+
+ // Format JSON with line numbers and higher indentation
+ const jsonString = JSON.stringify(data, null, 4); // Increased indentation to 4 spaces
+ const lines = jsonString.split('\n');
+
+ const formatJsonWithLineNumbers = () => {
+ // Calculate the width needed for line numbers based on total lines
+ const totalLines = lines.length;
+ const lineNumberWidth = Math.max(50, (totalLines.toString().length * 8) + 16); // 8px per digit + 16px padding
+
+ return lines.map((line, index) => {
+ const lineNumber = index + 1;
+ return (
+
+
+ {lineNumber}
+
+
+
+
+
+ );
+ });
+ };
+
+ const highlightJson = (line: string): string => {
+ let highlightedLine = line;
+
+ // Escape HTML first
+ highlightedLine = highlightedLine
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+
+ // Property names (keys) - strings followed by colon
+ highlightedLine = highlightedLine.replace(
+ /("(?:[^"\\]|\\.)*")\s*:/g,
+ '$1:'
+ );
+
+ // String values after colon (object values)
+ highlightedLine = highlightedLine.replace(
+ /:\s*("(?:[^"\\]|\\.)*")/g,
+ ': $1'
+ );
+
+ // String values in arrays (after [ or , but not after :)
+ highlightedLine = highlightedLine.replace(
+ /(\[\s*|,\s*)("(?:[^"\\]|\\.)*")/g,
+ '$1$2'
+ );
+
+ // Numbers after colon (object values)
+ highlightedLine = highlightedLine.replace(
+ /:\s*(-?\d+\.?\d*)/g,
+ ': $1'
+ );
+
+ // Numbers in arrays (after [ or , but not after :)
+ highlightedLine = highlightedLine.replace(
+ /(\[\s*|,\s*)(-?\d+\.?\d*)/g,
+ '$1$2'
+ );
+
+ // Booleans after colon
+ highlightedLine = highlightedLine.replace(
+ /:\s*(true|false)/g,
+ ': $1'
+ );
+
+ // Booleans in arrays
+ highlightedLine = highlightedLine.replace(
+ /(\[\s*|,\s*)(true|false)/g,
+ '$1$2'
+ );
+
+ // null after colon
+ highlightedLine = highlightedLine.replace(
+ /:\s*(null)/g,
+ ': $1'
+ );
+
+ // null in arrays
+ highlightedLine = highlightedLine.replace(
+ /(\[\s*|,\s*)(null)/g,
+ '$1$2'
+ );
+
+ // Brackets and braces
+ highlightedLine = highlightedLine.replace(
+ /([{}[\]])/g,
+ '$1'
+ );
+
+ // Commas
+ highlightedLine = highlightedLine.replace(
+ /(,)/g,
+ '$1'
+ );
+
+ return highlightedLine;
+ };
+
+ return (
+
+ {/* VS Code-like tab header */}
+
+
+ {filename}
+
+
+
+ {copySuccess ? : }
+
+
+
+
+
+
+
+
+
+ {formatJsonWithLineNumbers()}
+
+
+
+ );
+};
+
+export const SubmodelViewer: React.FC = ({
+ open,
+ onClose,
+ counterPartyId,
+ shellId,
+ dtrConnectorUrl,
+ submodel
+}) => {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [submodelData, setSubmodelData] = useState(null);
+ const [rightPanelTab, setRightPanelTab] = useState(0); // Separate state for right panel tabs
+ const [lastLoadedSubmodelId, setLastLoadedSubmodelId] = useState(null);
+ const isFetching = useRef(false);
+ const theme = useTheme();
+
+ const semanticIdValue = submodel.semanticId?.keys?.[0]?.value || '';
+
+ // Register addons on component mount
+ useEffect(() => {
+ // Register US Tariff Information addon if not already registered
+ if (!submodelAddonRegistry.getAddon('us-tariff-information')) {
+ submodelAddonRegistry.register(usTariffInformationAddon as unknown as import('./submodel-addons/shared/types').VersionedSubmodelAddon);
+ console.log('Registered US Tariff Information addon');
+ }
+ }, []);
+
+ // Check if there's a specialized addon for this submodel
+ const getSpecializedAddon = useCallback(() => {
+ if (!submodelData?.submodel || !semanticIdValue) {
+ return null;
+ }
+
+ try {
+ const resolution = submodelAddonRegistry.resolve(semanticIdValue, submodelData.submodel);
+ return resolution;
+ } catch (error) {
+ console.warn('Error resolving addon for semantic ID:', semanticIdValue, error);
+ return null;
+ }
+ }, [submodelData?.submodel, semanticIdValue]);
+
+ const fetchSubmodelData = useCallback(async (forceRefresh = false) => {
+ // Prevent multiple calls for the same submodel or if already fetching, unless it's a forced refresh
+ if (!forceRefresh && (lastLoadedSubmodelId === submodel.id || isFetching.current)) {
+ console.log('SubmodelViewer: Preventing duplicate API call for submodel:', submodel.id);
+ return;
+ }
+
+ console.log('SubmodelViewer: Fetching submodel data for:', submodel.id, forceRefresh ? '(forced refresh)' : '');
+ isFetching.current = true;
+ setLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetchSubmodel(
+ counterPartyId,
+ shellId,
+ submodel.id,
+ semanticIdValue
+ );
+ setSubmodelData(response);
+ setLastLoadedSubmodelId(submodel.id);
+ console.log('SubmodelViewer: Successfully fetched submodel data');
+ } catch (err) {
+ // Don't show error for cancelled requests
+ if (axios.isCancel(err)) {
+ console.log('SubmodelViewer: Request was cancelled');
+ return;
+ }
+ console.error('Error fetching submodel:', err);
+ setError(err instanceof Error ? err.message : 'Failed to fetch submodel data');
+ } finally {
+ setLoading(false);
+ isFetching.current = false;
+ }
+ }, [counterPartyId, shellId, submodel.id, semanticIdValue, lastLoadedSubmodelId]);
+
+ useEffect(() => {
+ if (open && submodel.id && counterPartyId && shellId) {
+ fetchSubmodelData();
+ }
+ }, [open, submodel.id, counterPartyId, shellId, fetchSubmodelData]);
+
+ // Reset data when submodel changes
+ useEffect(() => {
+ if (submodel.id !== lastLoadedSubmodelId) {
+ setSubmodelData(null);
+ setError(null);
+ }
+ }, [submodel.id, lastLoadedSubmodelId]);
+
+ // Reset state when dialog closes
+ useEffect(() => {
+ if (!open) {
+ setRightPanelTab(0);
+ setError(null);
+ isFetching.current = false;
+ }
+ }, [open]);
+
+ // Auto-select specialized view when data loads (if available)
+ useEffect(() => {
+ if (submodelData?.submodel && semanticIdValue) {
+ const hasSpecializedAddon = getSpecializedAddon();
+ if (hasSpecializedAddon) {
+ setRightPanelTab(0); // Specialized view first
+ } else {
+ setRightPanelTab(0); // JSON view (will be the only tab)
+ }
+ }
+ }, [submodelData, semanticIdValue, getSpecializedAddon]);
+
+ const handleRightPanelTabChange = (_event: React.SyntheticEvent, newValue: number) => {
+ setRightPanelTab(newValue);
+ };
+
+ const getRightPanelTabs = () => {
+ const tabs = [];
+ const hasSpecializedAddon = getSpecializedAddon();
+
+ if (hasSpecializedAddon) {
+ tabs.push(
+ }
+ iconPosition="start"
+ sx={{ minHeight: 'auto', padding: 0, py: 1, textTransform: 'none', fontWeight: 600 }}
+ />
+ );
+ }
+
+ tabs.push(
+ }
+ iconPosition="start"
+ sx={{ minHeight: 'auto', py: 1, textTransform: 'none', fontWeight: 600 }}
+ />
+ );
+
+ return tabs;
+ };
+
+ const getRightPanelContent = () => {
+ const hasSpecializedAddon = getSpecializedAddon();
+
+ if (hasSpecializedAddon && rightPanelTab === 0) {
+ return renderSpecializedView();
+ } else if (hasSpecializedAddon && rightPanelTab === 1) {
+ return renderJsonData();
+ } else if (!hasSpecializedAddon && rightPanelTab === 0) {
+ return renderJsonData();
+ }
+
+ return renderJsonData(); // fallback
+ };
+
+ const handleRefresh = () => {
+ fetchSubmodelData(true);
+ };
+
+ const handleDownloadJson = () => {
+ if (submodelData?.submodel) {
+ const jsonString = JSON.stringify(submodelData.submodel, null, 2);
+ const blob = new Blob([jsonString], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `submodel-${submodel.id}-${submodel.idShort || 'data'}.json`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ }
+ };
+
+ const handleShareEmail = () => {
+ if (submodelData?.submodel) {
+ const jsonString = JSON.stringify(submodelData.submodel, null, 2);
+ const subject = encodeURIComponent(`Submodel Data: ${submodel.idShort || 'Digital Twin Data'}`);
+ const body = encodeURIComponent(`Hello,
+
+I'm sharing submodel data with you:
+
+Digital Twin ID: ${shellId}
+Business Partner Number (BPN): ${counterPartyId}
+DTR Endpoint: ${dtrConnectorUrl || 'N/A'}
+Submodel ID: ${submodelData.submodelDescriptor.submodelId}
+Semantic ID: ${submodelData.submodelDescriptor.semanticId}
+Status: ${submodelData.submodelDescriptor.status}
+
+JSON Data:
+${jsonString}
+
+Best regards`);
+
+ const mailtoLink = `mailto:?subject=${subject}&body=${body}`;
+ window.open(mailtoLink, '_blank');
+ }
+ };
+
+ const renderSubmodelInfo = () => {
+ if (!submodelData) return null;
+
+ return (
+
+
+
+
+
+ Submodel Information
+
+
+
+
+
+
+ Submodel ID:
+
+
+ }
+ label={submodelData.submodelDescriptor.submodelId}
+ variant="outlined"
+ size="small"
+ sx={{
+ maxWidth: '100%',
+ '& .MuiChip-label': {
+ fontFamily: 'monospace',
+ fontSize: '0.75rem'
+ },
+ '& .MuiChip-icon': {
+ color: 'primary.main'
+ }
+ }}
+ />
+
+ navigator.clipboard.writeText(submodelData.submodelDescriptor.submodelId)}
+ sx={{
+ color: 'text.secondary',
+ '&:hover': {
+ color: 'success.main',
+ backgroundColor: 'rgba(76, 175, 80, 0.1)'
+ }
+ }}
+ >
+
+
+
+
+
+
+
+ Semantic ID:
+
+
+ }
+ label={submodelData.submodelDescriptor.semanticId}
+ variant="outlined"
+ size="small"
+ sx={{
+ maxWidth: '100%',
+ '& .MuiChip-label': {
+ fontFamily: 'monospace',
+ fontSize: '0.75rem'
+ },
+ '& .MuiChip-icon': {
+ color: 'secondary.main'
+ }
+ }}
+ />
+
+ navigator.clipboard.writeText(submodelData.submodelDescriptor.semanticId)}
+ sx={{
+ color: 'text.secondary',
+ '&:hover': {
+ color: 'success.main',
+ backgroundColor: 'rgba(76, 175, 80, 0.1)'
+ }
+ }}
+ >
+
+
+
+
+
+
+
+ Asset ID:
+
+
+ }
+ label={submodelData.submodelDescriptor.assetId}
+ variant="outlined"
+ size="small"
+ sx={{
+ maxWidth: '100%',
+ '& .MuiChip-label': {
+ fontFamily: 'monospace',
+ fontSize: '0.75rem'
+ },
+ '& .MuiChip-icon': {
+ color: 'warning.main'
+ }
+ }}
+ />
+
+ navigator.clipboard.writeText(submodelData.submodelDescriptor.assetId)}
+ sx={{
+ color: 'text.secondary',
+ '&:hover': {
+ color: 'success.main',
+ backgroundColor: 'rgba(76, 175, 80, 0.1)'
+ }
+ }}
+ >
+
+
+
+
+
+
+
+ Connector URL:
+
+
+ {submodelData.submodelDescriptor.connectorUrl}
+
+
+ {submodelData.submodelDescriptor.error && (
+
+
+
+ Error Details:
+
+
+ {submodelData.submodelDescriptor.error}
+
+
+
+ )}
+
+
+
+
+ {submodelData.dtr && (
+
+
+
+
+ DTR Information
+
+
+
+
+ Connector URL:
+
+
+ {submodelData.dtr.connectorUrl}
+
+
+
+
+ Asset ID:
+
+
+ }
+ label={submodelData.dtr.assetId}
+ variant="outlined"
+ size="small"
+ sx={{
+ maxWidth: '100%',
+ '& .MuiChip-label': {
+ fontFamily: 'monospace',
+ fontSize: '0.75rem'
+ },
+ '& .MuiChip-icon': {
+ color: 'warning.main'
+ }
+ }}
+ />
+
+ navigator.clipboard.writeText(submodelData.dtr!.assetId)}
+ sx={{
+ color: 'text.secondary',
+ '&:hover': {
+ color: 'success.main',
+ backgroundColor: 'rgba(76, 175, 80, 0.1)'
+ }
+ }}
+ >
+
+
+
+
+
+
+
+
+ )}
+
+ );
+ };
+
+ const renderJsonData = () => {
+ if (!submodelData?.submodel || Object.keys(submodelData.submodel).length === 0) {
+ return (
+
+ No submodel data available or data could not be retrieved.
+
+ );
+ }
+
+ // Generate the same filename as the download function
+ const filename = `submodel-${submodel.id}-${submodel.idShort || 'data'}.json`;
+
+ return ;
+ };
+
+ const renderSpecializedView = () => {
+ if (!submodelData?.submodel) {
+ return (
+
+ No submodel data available.
+
+ );
+ }
+
+ const addonResolution = getSpecializedAddon();
+ if (!addonResolution) {
+ return (
+
+ No specialized viewer found for this semantic ID.
+
+ );
+ }
+
+ const { addon } = addonResolution;
+ const AddonComponent = addon.component;
+
+ try {
+ return (
+
+ );
+ } catch (error) {
+ console.error('Error rendering specialized addon:', error);
+ return (
+
+ Error rendering specialized view: {error instanceof Error ? error.message : 'Unknown error'}
+
+ );
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/ichub-frontend/src/features/part-discovery/components/catalog-parts/CatalogPartsDiscovery.tsx b/ichub-frontend/src/features/part-discovery/components/catalog-parts/CatalogPartsDiscovery.tsx
new file mode 100644
index 00000000..79e1752a
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/catalog-parts/CatalogPartsDiscovery.tsx
@@ -0,0 +1,334 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+import MoreVert from "@mui/icons-material/MoreVert";
+import Launch from "@mui/icons-material/Launch";
+import ContentCopy from '@mui/icons-material/ContentCopy';
+import Download from '@mui/icons-material/Download';
+import CheckCircle from '@mui/icons-material/CheckCircle';
+import { Box, Typography, IconButton, Button, Tooltip, Menu } from "@mui/material";
+import { useState } from "react";
+import ReportProblemIcon from '@mui/icons-material/ReportProblem';
+import { DiscoveryCardChip } from "./DiscoveryCardChip";
+import { StatusVariants } from "../../../../types/statusVariants";
+import { ErrorNotFound } from "../../../../components/general/ErrorNotFound";
+import LoadingSpinner from "../../../../components/general/LoadingSpinner";
+import { AASData } from "../../../part-discovery/utils";
+
+export interface AppContent {
+ id?: string;
+ manufacturerId: string;
+ manufacturerPartId: string;
+ name?: string;
+ category?: string;
+ status?: StatusVariants;
+ dtrIndex?: number; // DTR index for display
+ shellId?: string; // Shell ID (AAS ID) for display
+ idShort?: string; // idShort for display
+ rawTwinData?: AASData; // Raw AAS/shell data for download
+}
+
+export interface CardDecisionProps {
+ items: AppContent[];
+ onClick: (e: string) => void;
+ onRegisterClick?: (manufacturerId: string, manufacturerPartId: string) => void;
+ isLoading: boolean;
+}
+
+export const CatalogPartsDiscovery = ({
+ items,
+ onClick,
+ isLoading
+}: CardDecisionProps) => {
+ const [anchorEl, setAnchorEl] = useState(null);
+ const [selectedItem, setSelectedItem] = useState(null);
+ const [copySuccess, setCopySuccess] = useState(false);
+ const open = Boolean(anchorEl);
+
+ const handleMenuClick = (event: React.MouseEvent, item: AppContent) => {
+ event.stopPropagation();
+ setAnchorEl(event.currentTarget);
+ setSelectedItem(item);
+ };
+
+ const handleMenuClose = () => {
+ setAnchorEl(null);
+ setSelectedItem(null);
+ };
+
+ const handleCopyShellId = async () => {
+ if (selectedItem?.shellId) {
+ try {
+ await navigator.clipboard.writeText(selectedItem.shellId);
+ console.log('Shell ID copied to clipboard:', selectedItem.shellId);
+ setCopySuccess(true);
+
+ // Close menu after showing feedback for 1.5 seconds
+ setTimeout(() => {
+ handleMenuClose();
+ // Reset success state after menu closes
+ setTimeout(() => {
+ setCopySuccess(false);
+ }, 300);
+ }, 1500);
+ } catch (err) {
+ console.error('Failed to copy Shell ID:', err);
+ handleMenuClose();
+ }
+ } else {
+ handleMenuClose();
+ }
+ };
+
+ const handleDownloadTwinData = () => {
+ if (selectedItem?.rawTwinData) {
+ try {
+ const jsonString = JSON.stringify(selectedItem.rawTwinData, null, 2);
+ const blob = new Blob([jsonString], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `twin-${selectedItem.manufacturerPartId || selectedItem.shellId || 'data'}.json`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+
+ console.log('Twin data downloaded successfully');
+ } catch (err) {
+ console.error('Failed to download twin data:', err);
+ }
+ }
+ handleMenuClose();
+ };
+
+ return (
+ <>
+
+ {isLoading && (
+
+ )}
+ {!isLoading && items.length === 0 && (
+
+ )}
+ {items.map((item) => {
+ const name = item.name ?? "";
+ const productId = item.manufacturerId + "/" + item.manufacturerPartId;
+ return (
+
+
+
+
+
+
+
+
+ {item.rawTwinData && (
+
+ {
+ e.stopPropagation();
+ if (item.rawTwinData) {
+ try {
+ const jsonString = JSON.stringify(item.rawTwinData, null, 2);
+ const blob = new Blob([jsonString], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `twin-${item.manufacturerPartId || item.shellId || 'data'}.json`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+
+ console.log('Twin data downloaded successfully');
+ } catch (err) {
+ console.error('Failed to download twin data:', err);
+ }
+ }
+ }}
+ >
+
+
+
+ )}
+
+ handleMenuClick(e, item)}
+ >
+
+
+
+
+
+
+
+ {name}
+
+ {(item.idShort || item.shellId) && (
+
+ {item.idShort || item.shellId}
+
+ )}
+
+
+ {item.category}
+
+
+
+ }
+ onClick={(e) => {
+ e.stopPropagation(); // Prevent card click
+ onClick(productId);
+ }}
+ >
+ View
+
+
+
+
+ );
+ })}
+
+ {/* More options menu */}
+
+
+ >
+ );
+};
diff --git a/ichub-frontend/src/features/part-discovery/components/catalog-parts/DiscoveryCardChip.tsx b/ichub-frontend/src/features/part-discovery/components/catalog-parts/DiscoveryCardChip.tsx
new file mode 100644
index 00000000..c4264ce4
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/catalog-parts/DiscoveryCardChip.tsx
@@ -0,0 +1,135 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import PersonIcon from '@mui/icons-material/Person'
+import { type Palette, useTheme } from '@mui/material'
+import MuiChip from '@mui/material/Chip'
+import { StatusVariants } from '../../../../types/statusVariants'
+
+export interface CardChipProps {
+ status?: StatusVariants
+ statusText?: string
+ dtrIndex?: number
+ useDtrDisplay?: boolean
+}
+
+interface ChipStyle {
+ color: keyof Palette['chip']
+ backgroundColor: keyof Palette['chip']
+ border: keyof Palette['chip']
+}
+
+const statusStyles: Record = {
+ [StatusVariants.registered]: {
+ color: 'registered',
+ backgroundColor: 'black',
+ border: 'bgRegistered',
+ },
+ [StatusVariants.shared]: {
+ color: 'black',
+ backgroundColor: 'warning',
+ border: 'none',
+ },
+ [StatusVariants.draft]: {
+ color: 'bgDefault',
+ backgroundColor: 'none',
+ border: 'borderDraft',
+ },
+ [StatusVariants.pending]: {
+ color: 'inReview',
+ backgroundColor: 'bgInReview',
+ border: 'inReview',
+ },
+ default: {
+ color: 'default',
+ backgroundColor: 'bgDefault',
+ border: 'none',
+ }
+}
+
+// Helper function to get consistent colors for DTR identifiers
+const getDtrColor = (dtrIndex: number) => {
+ const baseColors = [
+ { bg: 'rgba(76, 175, 80, 0.9)', color: 'white' }, // Green
+ { bg: 'rgba(33, 150, 243, 0.9)', color: 'white' }, // Blue
+ { bg: 'rgba(255, 152, 0, 0.9)', color: 'white' }, // Orange
+ { bg: 'rgba(156, 39, 176, 0.9)', color: 'white' }, // Purple
+ { bg: 'rgba(244, 67, 54, 0.9)', color: 'white' }, // Red
+ { bg: 'rgba(0, 188, 212, 0.9)', color: 'white' }, // Cyan
+ { bg: 'rgba(139, 195, 74, 0.9)', color: 'white' }, // Light Green
+ { bg: 'rgba(121, 85, 72, 0.9)', color: 'white' }, // Brown
+ ];
+
+ const colorIndex = dtrIndex % baseColors.length;
+ const variation = Math.floor(dtrIndex / baseColors.length);
+
+ // For DTRs beyond 8, add opacity variations to distinguish them
+ const baseColor = baseColors[colorIndex];
+ const opacity = Math.max(0.7, 1 - (variation * 0.1)); // Gradually reduce opacity
+
+ return {
+ bg: baseColor.bg.replace('0.9)', `${opacity})`),
+ color: baseColor.color
+ };
+};
+
+export const DiscoveryCardChip = ({ status, statusText, dtrIndex, useDtrDisplay }: CardChipProps) => {
+ const theme = useTheme()
+
+ // If DTR display is requested and dtrIndex is provided, use DTR styling
+ if (useDtrDisplay && dtrIndex !== undefined) {
+ const dtrColors = getDtrColor(dtrIndex);
+ return (
+
+ );
+ }
+
+ // Otherwise, use original status styling
+ const statusKey = status && statusStyles[status] ? status : 'default'
+ const { color, backgroundColor, border } = statusStyles[statusKey]
+
+ return (
+ :undefined}
+ />
+ )
+}
diff --git a/ichub-frontend/src/features/part-discovery/components/index.ts b/ichub-frontend/src/features/part-discovery/components/index.ts
new file mode 100644
index 00000000..7c5fc17c
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/index.ts
@@ -0,0 +1,32 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+export { PaginationControls } from './PaginationControls';
+export { SearchModeToggle } from './SearchModeToggle';
+export { FilterChips } from './FilterChips';
+export { PartnerSearch } from './PartnerSearch';
+export { SingleTwinSearch } from './SingleTwinSearch';
+export { SearchHeader } from './SearchHeader';
+export { SingleTwinResult } from './SingleTwinResult';
+export { default as SerializedPartsTable } from './SerializedPartsTable';
+export { default as PartsDiscoverySidebar } from './PartsDiscoverySidebar';
+export { CatalogPartsDiscovery } from './catalog-parts/CatalogPartsDiscovery';
diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/BaseAddon.tsx b/ichub-frontend/src/features/part-discovery/components/submodel-addons/BaseAddon.tsx
new file mode 100644
index 00000000..294f4a64
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/BaseAddon.tsx
@@ -0,0 +1,126 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+import React from 'react';
+import { Box, Typography, Card, CardContent, Chip } from '@mui/material';
+import InfoIcon from '@mui/icons-material/Info';
+import { SubmodelAddonProps } from './types';
+
+/**
+ * Base wrapper component for submodel add-ons
+ */
+export const SubmodelAddonWrapper: React.FC<{
+ title: string;
+ subtitle?: string;
+ children: React.ReactNode;
+ actions?: React.ReactNode;
+}> = ({ title, subtitle, children, actions }) => {
+ return (
+
+
+
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {actions && (
+
+ {actions}
+
+ )}
+
+
+ {children}
+
+
+ );
+};
+
+/**
+ * Default fallback component when no specific add-on is available
+ */
+export const DefaultSubmodelAddon: React.FC = ({
+ data,
+ semanticId
+}) => {
+ return (
+
+
+
+
+
+
+ Submodel Information
+
+
+
+
+
+ Semantic ID:
+
+
+
+
+
+ Data Structure:
+
+
+ This submodel contains {Object.keys(data).length} top-level properties.
+ No specialized visualization is available for this semantic ID.
+
+
+
+
+
+
+ );
+};
diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/registry.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/registry.ts
new file mode 100644
index 00000000..7b80f88d
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/registry.ts
@@ -0,0 +1,146 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+import { SubmodelAddonConfig, SubmodelAddonRegistry, ParsedSemanticId } from './types';
+
+/**
+ * Utility function to parse semantic ID from string
+ */
+function parseSemanticId(semanticId: string): ParsedSemanticId | null {
+ try {
+ // Parse semantic ID format: urn:samm:org.eclipse.esmf.samm:example:1.0.0#Property
+ const match = semanticId.match(/^urn:(samm|bamm):([^:]+):([^:]+):(\d+)\.(\d+)\.(\d+)(?:#(.+))?$/);
+
+ if (!match) {
+ return null;
+ }
+
+ const [, prefix, namespace, name, major, minor, patch, fragment] = match;
+
+ return {
+ prefix: prefix as 'samm' | 'bamm',
+ namespace,
+ name,
+ version: {
+ major: parseInt(major, 10),
+ minor: parseInt(minor, 10),
+ patch: parseInt(patch, 10)
+ },
+ fragment,
+ originalId: semanticId
+ };
+ } catch (error) {
+ console.warn('Failed to parse semantic ID:', semanticId, error);
+ return null;
+ }
+}
+
+/**
+ * Check if a parsed semantic ID matches the add-on's criteria
+ */
+function addonCanHandle(config: SubmodelAddonConfig, parsedSemanticId: ParsedSemanticId): boolean {
+ // Check namespace and name match
+ if (config.semanticNamespace !== parsedSemanticId.namespace ||
+ config.semanticName !== parsedSemanticId.name) {
+ return false;
+ }
+
+ // Check version compatibility
+ const version = parsedSemanticId.version;
+ const { supportedVersions } = config;
+
+ if (supportedVersions.exact) {
+ return version.major === supportedVersions.exact.major &&
+ version.minor === supportedVersions.exact.minor &&
+ version.patch === supportedVersions.exact.patch;
+ }
+
+ if (supportedVersions.min) {
+ const minVersion = supportedVersions.min;
+ if (version.major < minVersion.major ||
+ (version.major === minVersion.major && version.minor < minVersion.minor) ||
+ (version.major === minVersion.major && version.minor === minVersion.minor && version.patch < minVersion.patch)) {
+ return false;
+ }
+ }
+
+ if (supportedVersions.max) {
+ const maxVersion = supportedVersions.max;
+ if (version.major > maxVersion.major ||
+ (version.major === maxVersion.major && version.minor > maxVersion.minor) ||
+ (version.major === maxVersion.major && version.minor === maxVersion.minor && version.patch > maxVersion.patch)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Create a new submodel add-on registry
+ */
+export function createSubmodelAddonRegistry(): SubmodelAddonRegistry {
+ const addons = new Map();
+
+ return {
+ addons,
+
+ register(config: SubmodelAddonConfig) {
+ // If no custom canHandle function provided, create a default one
+ if (!config.canHandle) {
+ config.canHandle = (parsedSemanticId: ParsedSemanticId) =>
+ addonCanHandle(config, parsedSemanticId);
+ }
+
+ addons.set(config.id, config);
+ console.log(`Registered submodel add-on: ${config.name} (${config.id})`);
+ },
+
+ getAddon(semanticId: string): SubmodelAddonConfig | null {
+ const parsedSemanticId = parseSemanticId(semanticId);
+ if (!parsedSemanticId) {
+ return null;
+ }
+
+ const candidates = Array.from(addons.values())
+ .filter(addon => addon.canHandle(parsedSemanticId))
+ .sort((a, b) => b.priority - a.priority); // Sort by priority descending
+
+ return candidates.length > 0 ? candidates[0] : null;
+ },
+
+ getCompatibleAddons(parsedSemanticId: ParsedSemanticId): SubmodelAddonConfig[] {
+ return Array.from(addons.values())
+ .filter(addon => addon.canHandle(parsedSemanticId))
+ .sort((a, b) => b.priority - a.priority); // Sort by priority descending
+ },
+
+ getAllAddons(): SubmodelAddonConfig[] {
+ return Array.from(addons.values()).sort((a, b) => a.name.localeCompare(b.name));
+ }
+ };
+}
+
+/**
+ * Global registry instance
+ */
+export const submodelAddonRegistry = createSubmodelAddonRegistry();
diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/registry.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/registry.ts
new file mode 100644
index 00000000..ac74c8b5
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/registry.ts
@@ -0,0 +1,395 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+import {
+ VersionedSubmodelAddon,
+ SubmodelAddonRegistryEntry,
+ AddonResolutionResult,
+ SubmodelAddonError,
+ SubmodelAddonErrorType,
+ SubmodelAddonSystemConfig,
+} from './types';
+import { isSemanticIdForModel, parseSemanticId } from './semantic-id-utils';
+
+/**
+ * Default configuration for the submodel addon system
+ */
+const DEFAULT_CONFIG: SubmodelAddonSystemConfig = {
+ strictMode: false,
+ maxCacheSize: 50,
+ enablePerformanceMonitoring: false,
+};
+
+/**
+ * Registry for managing submodel addons
+ *
+ * This registry provides a centralized way to register, discover, and resolve
+ * submodel addons based on semantic IDs. It supports versioning, prioritization,
+ * and fallback mechanisms.
+ */
+export class SubmodelAddonRegistry {
+ private readonly addons = new Map();
+ private readonly semanticIdCache = new Map();
+ private readonly config: SubmodelAddonSystemConfig;
+
+ constructor(config: Partial = {}) {
+ this.config = { ...DEFAULT_CONFIG, ...config };
+ }
+
+ /**
+ * Registers a submodel addon
+ *
+ * @param addon - The addon to register
+ * @param options - Registration options
+ * @throws {SubmodelAddonError} If registration fails
+ */
+ register(
+ addon: VersionedSubmodelAddon,
+ options: {
+ enabled?: boolean;
+ loadPriority?: number;
+ replaceExisting?: boolean;
+ } = {}
+ ): void {
+ const { enabled = true, loadPriority = 0, replaceExisting = false } = options;
+
+ // Validate addon configuration
+ this.validateAddon(addon);
+
+ // Check if addon already exists
+ if (this.addons.has(addon.id) && !replaceExisting) {
+ throw new SubmodelAddonError(
+ SubmodelAddonErrorType.REGISTRATION_ERROR,
+ `Addon with ID '${addon.id}' is already registered. Use replaceExisting: true to override.`,
+ undefined,
+ addon.id
+ );
+ }
+
+ // Register the addon
+ this.addons.set(addon.id, {
+ addon,
+ enabled,
+ loadPriority,
+ });
+
+ // Clear relevant cache entries
+ this.clearCacheForAddon(addon);
+
+ console.debug(`Registered submodel addon: ${addon.id} (${addon.name})`);
+ }
+
+ /**
+ * Unregisters a submodel addon
+ *
+ * @param addonId - The ID of the addon to unregister
+ * @returns True if the addon was found and removed
+ */
+ unregister(addonId: string): boolean {
+ const entry = this.addons.get(addonId);
+ if (!entry) {
+ return false;
+ }
+
+ this.addons.delete(addonId);
+ this.clearCacheForAddon(entry.addon);
+
+ console.debug(`Unregistered submodel addon: ${addonId}`);
+ return true;
+ }
+
+ /**
+ * Resolves the best addon for a given semantic ID
+ *
+ * @param semanticId - The semantic ID to resolve
+ * @param data - The submodel data (optional, for validation)
+ * @returns The resolved addon or null if none found
+ */
+ resolve(
+ semanticId: string,
+ data?: unknown
+ ): AddonResolutionResult | null {
+ // Check cache first
+ const cached = this.semanticIdCache.get(semanticId);
+ if (cached) {
+ // Validate data if provided
+ if (data !== undefined && !cached.addon.isValidData(semanticId, data)) {
+ return null;
+ }
+ return cached as AddonResolutionResult;
+ }
+
+ // Find matching addons
+ const candidates = this.findMatchingAddons(semanticId, data);
+ if (candidates.length === 0) {
+ if (this.config.strictMode) {
+ throw new SubmodelAddonError(
+ SubmodelAddonErrorType.ADDON_NOT_FOUND,
+ `No addon found for semantic ID: ${semanticId}`,
+ semanticId
+ );
+ }
+ return null;
+ }
+
+ // Select the best candidate
+ const best = this.selectBestAddon(candidates, semanticId);
+
+ // Cache the result
+ if (this.semanticIdCache.size >= this.config.maxCacheSize) {
+ // Simple LRU: remove oldest entry
+ const firstKey = this.semanticIdCache.keys().next().value;
+ if (firstKey) {
+ this.semanticIdCache.delete(firstKey);
+ }
+ }
+ this.semanticIdCache.set(semanticId, best);
+
+ return best as AddonResolutionResult;
+ }
+
+ /**
+ * Gets all registered addons
+ *
+ * @param includeDisabled - Whether to include disabled addons
+ * @returns Array of addon entries
+ */
+ getAllAddons(includeDisabled = false): SubmodelAddonRegistryEntry[] {
+ return Array.from(this.addons.values()).filter(
+ entry => includeDisabled || entry.enabled
+ );
+ }
+
+ /**
+ * Gets addon by ID
+ *
+ * @param addonId - The addon ID
+ * @returns The addon entry or undefined
+ */
+ getAddon(addonId: string): SubmodelAddonRegistryEntry | undefined {
+ return this.addons.get(addonId);
+ }
+
+ /**
+ * Enables or disables an addon
+ *
+ * @param addonId - The addon ID
+ * @param enabled - Whether to enable the addon
+ * @returns True if the addon was found and updated
+ */
+ setAddonEnabled(addonId: string, enabled: boolean): boolean {
+ const entry = this.addons.get(addonId);
+ if (!entry) {
+ return false;
+ }
+
+ entry.enabled = enabled;
+ this.clearCacheForAddon(entry.addon);
+ return true;
+ }
+
+ /**
+ * Clears the resolution cache
+ */
+ clearCache(): void {
+ this.semanticIdCache.clear();
+ }
+
+ /**
+ * Gets statistics about the registry
+ */
+ getStats(): {
+ totalAddons: number;
+ enabledAddons: number;
+ cacheSize: number;
+ namespaces: string[];
+ } {
+ const entries = Array.from(this.addons.values());
+ const enabledEntries = entries.filter(e => e.enabled);
+ const namespaces = [...new Set(entries.map(e => e.addon.namespace))];
+
+ return {
+ totalAddons: entries.length,
+ enabledAddons: enabledEntries.length,
+ cacheSize: this.semanticIdCache.size,
+ namespaces,
+ };
+ }
+
+ /**
+ * Validates an addon configuration
+ */
+ private validateAddon(addon: VersionedSubmodelAddon): void {
+ if (!addon.id || typeof addon.id !== 'string') {
+ throw new SubmodelAddonError(
+ SubmodelAddonErrorType.REGISTRATION_ERROR,
+ 'Addon must have a valid string ID',
+ undefined,
+ addon.id
+ );
+ }
+
+ if (!addon.name || typeof addon.name !== 'string') {
+ throw new SubmodelAddonError(
+ SubmodelAddonErrorType.REGISTRATION_ERROR,
+ 'Addon must have a valid name',
+ undefined,
+ addon.id
+ );
+ }
+
+ if (!addon.namespace || typeof addon.namespace !== 'string') {
+ throw new SubmodelAddonError(
+ SubmodelAddonErrorType.REGISTRATION_ERROR,
+ 'Addon must have a valid namespace',
+ undefined,
+ addon.id
+ );
+ }
+
+ if (!addon.modelName || typeof addon.modelName !== 'string') {
+ throw new SubmodelAddonError(
+ SubmodelAddonErrorType.REGISTRATION_ERROR,
+ 'Addon must have a valid modelName',
+ undefined,
+ addon.id
+ );
+ }
+
+ if (!Array.isArray(addon.supportedSemanticIds) || addon.supportedSemanticIds.length === 0) {
+ throw new SubmodelAddonError(
+ SubmodelAddonErrorType.REGISTRATION_ERROR,
+ 'Addon must support at least one semantic ID',
+ undefined,
+ addon.id
+ );
+ }
+
+ if (typeof addon.isValidData !== 'function') {
+ throw new SubmodelAddonError(
+ SubmodelAddonErrorType.REGISTRATION_ERROR,
+ 'Addon must provide a valid isValidData function',
+ undefined,
+ addon.id
+ );
+ }
+
+ if (!addon.component) {
+ throw new SubmodelAddonError(
+ SubmodelAddonErrorType.REGISTRATION_ERROR,
+ 'Addon must provide a React component',
+ undefined,
+ addon.id
+ );
+ }
+ }
+
+ /**
+ * Finds addons that match the given semantic ID
+ */
+ private findMatchingAddons(semanticId: string, data?: unknown): SubmodelAddonRegistryEntry[] {
+ const parsed = parseSemanticId(semanticId);
+ if (!parsed) {
+ return [];
+ }
+
+ return Array.from(this.addons.values()).filter(entry => {
+ if (!entry.enabled) {
+ return false;
+ }
+
+ const { addon } = entry;
+
+ // Check if addon supports this namespace and model
+ if (!isSemanticIdForModel(semanticId, addon.namespace, addon.modelName)) {
+ return false;
+ }
+
+ // Check if addon explicitly supports this semantic ID
+ if (!addon.supportedSemanticIds.includes(semanticId)) {
+ return false;
+ }
+
+ // Validate data if provided
+ if (data !== undefined && !addon.isValidData(semanticId, data)) {
+ return false;
+ }
+
+ return true;
+ });
+ }
+
+ /**
+ * Selects the best addon from candidates
+ */
+ private selectBestAddon(
+ candidates: SubmodelAddonRegistryEntry[],
+ semanticId: string
+ ): AddonResolutionResult {
+ // Sort by priority (descending) and load priority (ascending)
+ const sorted = candidates.sort((a, b) => {
+ if (a.addon.priority !== b.addon.priority) {
+ return b.addon.priority - a.addon.priority; // Higher priority first
+ }
+ return a.loadPriority - b.loadPriority; // Lower load priority first
+ });
+
+ const best = sorted[0];
+ const isPreferred = sorted.length === 1 || best.addon.priority > sorted[1].addon.priority;
+ const confidence = this.calculateConfidence(best.addon, semanticId);
+
+ return {
+ addon: best.addon,
+ isPreferred,
+ confidence,
+ };
+ }
+
+ /**
+ * Calculates confidence score for an addon match
+ */
+ private calculateConfidence(addon: VersionedSubmodelAddon, semanticId: string): number {
+ // Base confidence for exact semantic ID match
+ let confidence = addon.supportedSemanticIds.includes(semanticId) ? 1.0 : 0.8;
+
+ // Adjust based on addon priority
+ confidence *= Math.min(1.0, (addon.priority + 10) / 20);
+
+ return Math.max(0, Math.min(1, confidence));
+ }
+
+ /**
+ * Clears cache entries related to an addon
+ */
+ private clearCacheForAddon(addon: VersionedSubmodelAddon): void {
+ for (const [semanticId, result] of this.semanticIdCache.entries()) {
+ if (result.addon.id === addon.id) {
+ this.semanticIdCache.delete(semanticId);
+ }
+ }
+ }
+}
+
+/**
+ * Global singleton instance of the submodel addon registry
+ */
+export const submodelAddonRegistry = new SubmodelAddonRegistry();
diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/semantic-id-utils.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/semantic-id-utils.ts
new file mode 100644
index 00000000..f7e4de39
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/semantic-id-utils.ts
@@ -0,0 +1,269 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+/**
+ * Generic utilities for parsing and working with Catena-X semantic model IDs
+ *
+ * Semantic ID format: urn:samm:io.catenax.{namespace}:{version}#{modelName}
+ * Examples:
+ * - urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation
+ * - urn:samm:io.catenax.single_level_bom_as_built:3.0.0#SingleLevelBomAsBuilt
+ * - urn:samm:io.catenax.shared.shopfloor_information_types:2.0.0#ShopfloorInformationTypes
+ */
+
+/**
+ * Parsed semantic ID components
+ */
+export interface ParsedSemanticId {
+ /** The namespace part (e.g., 'us_tariff_information', 'single_level_bom_as_built') */
+ namespace: string;
+ /** The version (e.g., '1.0.0', '3.0.0') */
+ version: string;
+ /** The model name (e.g., 'UsTariffInformation', 'SingleLevelBomAsBuilt') */
+ modelName: string;
+ /** The complete original URN */
+ fullUrn: string;
+}
+
+/**
+ * Regular expression for parsing Catena-X semantic IDs
+ */
+const SEMANTIC_ID_REGEX = /^urn:samm:io\.catenax\.([\w.]+):(\d+\.\d+\.\d+)#(\w+)$/;
+
+/**
+ * Parses a Catena-X semantic ID into its components
+ *
+ * @param semanticId - The semantic ID to parse
+ * @returns Parsed components or null if invalid format
+ *
+ * @example
+ * ```typescript
+ * const parsed = parseSemanticId('urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation');
+ * // Returns: {
+ * // namespace: 'us_tariff_information',
+ * // version: '1.0.0',
+ * // modelName: 'UsTariffInformation',
+ * // fullUrn: 'urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation'
+ * // }
+ * ```
+ */
+export function parseSemanticId(semanticId: string): ParsedSemanticId | null {
+ const match = semanticId.match(SEMANTIC_ID_REGEX);
+ if (!match) {
+ return null;
+ }
+
+ return {
+ namespace: match[1],
+ version: match[2],
+ modelName: match[3],
+ fullUrn: semanticId
+ };
+}
+
+/**
+ * Extracts the version from a semantic ID
+ *
+ * @param semanticId - The semantic ID
+ * @returns The version string or null if invalid
+ *
+ * @example
+ * ```typescript
+ * extractVersionFromSemanticId('urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation');
+ * // Returns: '1.0.0'
+ * ```
+ */
+export function extractVersionFromSemanticId(semanticId: string): string | null {
+ const parsed = parseSemanticId(semanticId);
+ return parsed?.version ?? null;
+}
+
+/**
+ * Extracts the model name from a semantic ID
+ *
+ * @param semanticId - The semantic ID
+ * @returns The model name or null if invalid
+ *
+ * @example
+ * ```typescript
+ * extractModelNameFromSemanticId('urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation');
+ * // Returns: 'UsTariffInformation'
+ * ```
+ */
+export function extractModelNameFromSemanticId(semanticId: string): string | null {
+ const parsed = parseSemanticId(semanticId);
+ return parsed?.modelName ?? null;
+}
+
+/**
+ * Extracts the namespace from a semantic ID
+ *
+ * @param semanticId - The semantic ID
+ * @returns The namespace or null if invalid
+ *
+ * @example
+ * ```typescript
+ * extractNamespaceFromSemanticId('urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation');
+ * // Returns: 'us_tariff_information'
+ * ```
+ */
+export function extractNamespaceFromSemanticId(semanticId: string): string | null {
+ const parsed = parseSemanticId(semanticId);
+ return parsed?.namespace ?? null;
+}
+
+/**
+ * Checks if a semantic ID matches a specific model type
+ *
+ * @param semanticId - The semantic ID to check
+ * @param namespace - The expected namespace
+ * @param modelName - The expected model name
+ * @returns True if the semantic ID matches the specified model
+ *
+ * @example
+ * ```typescript
+ * isSemanticIdForModel(
+ * 'urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation',
+ * 'us_tariff_information',
+ * 'UsTariffInformation'
+ * );
+ * // Returns: true
+ * ```
+ */
+export function isSemanticIdForModel(
+ semanticId: string,
+ namespace: string,
+ modelName: string
+): boolean {
+ const parsed = parseSemanticId(semanticId);
+ return parsed?.namespace === namespace && parsed?.modelName === modelName;
+}
+
+/**
+ * Checks if a semantic ID is valid Catena-X format
+ *
+ * @param semanticId - The semantic ID to validate
+ * @returns True if the semantic ID is valid
+ *
+ * @example
+ * ```typescript
+ * isValidSemanticId('urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation');
+ * // Returns: true
+ *
+ * isValidSemanticId('invalid-id');
+ * // Returns: false
+ * ```
+ */
+export function isValidSemanticId(semanticId: string): boolean {
+ return parseSemanticId(semanticId) !== null;
+}
+
+/**
+ * Creates a semantic ID from components
+ *
+ * @param namespace - The namespace
+ * @param version - The version
+ * @param modelName - The model name
+ * @returns The constructed semantic ID
+ *
+ * @example
+ * ```typescript
+ * createSemanticId('us_tariff_information', '1.0.0', 'UsTariffInformation');
+ * // Returns: 'urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation'
+ * ```
+ */
+export function createSemanticId(namespace: string, version: string, modelName: string): string {
+ return `urn:samm:io.catenax.${namespace}:${version}#${modelName}`;
+}
+
+/**
+ * Compares two version strings using semantic versioning rules
+ *
+ * @param version1 - First version to compare
+ * @param version2 - Second version to compare
+ * @returns -1 if version1 < version2, 0 if equal, 1 if version1 > version2
+ *
+ * @example
+ * ```typescript
+ * compareVersions('1.0.0', '1.1.0'); // Returns: -1
+ * compareVersions('2.0.0', '1.9.9'); // Returns: 1
+ * compareVersions('1.0.0', '1.0.0'); // Returns: 0
+ * ```
+ */
+export function compareVersions(version1: string, version2: string): number {
+ const parts1 = version1.split('.').map(Number);
+ const parts2 = version2.split('.').map(Number);
+
+ for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
+ const part1 = parts1[i] || 0;
+ const part2 = parts2[i] || 0;
+
+ if (part1 < part2) return -1;
+ if (part1 > part2) return 1;
+ }
+
+ return 0;
+}
+
+/**
+ * Gets the latest version from a list of semantic IDs for the same model
+ *
+ * @param semanticIds - Array of semantic IDs
+ * @param namespace - The namespace to filter by
+ * @param modelName - The model name to filter by
+ * @returns The semantic ID with the latest version, or null if none found
+ *
+ * @example
+ * ```typescript
+ * const ids = [
+ * 'urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation',
+ * 'urn:samm:io.catenax.us_tariff_information:1.1.0#UsTariffInformation',
+ * 'urn:samm:io.catenax.us_tariff_information:2.0.0#UsTariffInformation'
+ * ];
+ * getLatestVersionSemanticId(ids, 'us_tariff_information', 'UsTariffInformation');
+ * // Returns: 'urn:samm:io.catenax.us_tariff_information:2.0.0#UsTariffInformation'
+ * ```
+ */
+export function getLatestVersionSemanticId(
+ semanticIds: string[],
+ namespace: string,
+ modelName: string
+): string | null {
+ const matchingIds = semanticIds.filter(id =>
+ isSemanticIdForModel(id, namespace, modelName)
+ );
+
+ if (matchingIds.length === 0) {
+ return null;
+ }
+
+ return matchingIds.reduce((latest, current) => {
+ const latestVersion = extractVersionFromSemanticId(latest);
+ const currentVersion = extractVersionFromSemanticId(current);
+
+ if (!latestVersion || !currentVersion) {
+ return latest;
+ }
+
+ return compareVersions(currentVersion, latestVersion) > 0 ? current : latest;
+ });
+}
diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/types.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/types.ts
new file mode 100644
index 00000000..90b0a52e
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/types.ts
@@ -0,0 +1,147 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+import { ComponentType } from 'react';
+
+/**
+ * Base interface for all submodel addon configurations
+ */
+export interface SubmodelAddonBase {
+ /** Unique identifier for the addon */
+ id: string;
+ /** Display name for the addon */
+ name: string;
+ /** Brief description of what this addon visualizes */
+ description: string;
+ /** Semantic model namespace (e.g., 'us_tariff_information') */
+ namespace: string;
+ /** Semantic model name (e.g., 'UsTariffInformation') */
+ modelName: string;
+ /** Array of supported semantic IDs */
+ supportedSemanticIds: readonly string[];
+ /** Priority for addon selection (higher = preferred) */
+ priority: number;
+}
+
+/**
+ * Versioned submodel addon configuration
+ */
+export interface VersionedSubmodelAddon extends SubmodelAddonBase {
+ /** Type guard function to validate data structure */
+ isValidData: (semanticId: string, data: unknown) => data is TData;
+ /** React component for rendering the submodel data */
+ component: ComponentType>;
+}
+
+/**
+ * Props passed to submodel addon components
+ */
+export interface SubmodelAddonProps {
+ /** The semantic ID of the submodel */
+ semanticId: string;
+ /** The validated submodel data */
+ data: TData;
+ /** Optional metadata about the submodel */
+ metadata?: SubmodelMetadata;
+ /** Callback for error handling */
+ onError?: (error: Error) => void;
+ /** Additional props that can be passed down */
+ [key: string]: unknown;
+}
+
+/**
+ * Metadata about a submodel
+ */
+export interface SubmodelMetadata {
+ /** Source of the submodel data */
+ source?: string;
+ /** Timestamp when the data was last updated */
+ lastUpdated?: Date;
+ /** Version information */
+ version?: string;
+ /** Additional metadata */
+ [key: string]: unknown;
+}
+
+/**
+ * Registry entry for a submodel addon
+ */
+export interface SubmodelAddonRegistryEntry {
+ /** The addon configuration */
+ addon: VersionedSubmodelAddon;
+ /** Whether the addon is enabled */
+ enabled: boolean;
+ /** Load priority (for lazy loading) */
+ loadPriority: number;
+}
+
+/**
+ * Error types that can occur in submodel addon system
+ */
+export enum SubmodelAddonErrorType {
+ ADDON_NOT_FOUND = 'ADDON_NOT_FOUND',
+ INVALID_DATA = 'INVALID_DATA',
+ COMPONENT_ERROR = 'COMPONENT_ERROR',
+ REGISTRATION_ERROR = 'REGISTRATION_ERROR',
+}
+
+/**
+ * Error class for submodel addon related errors
+ */
+export class SubmodelAddonError extends Error {
+ constructor(
+ public type: SubmodelAddonErrorType,
+ message: string,
+ public semanticId?: string,
+ public addonId?: string,
+ public cause?: Error
+ ) {
+ super(message);
+ this.name = 'SubmodelAddonError';
+ }
+}
+
+/**
+ * Result of addon resolution
+ */
+export interface AddonResolutionResult {
+ /** The resolved addon */
+ addon: VersionedSubmodelAddon;
+ /** Whether this is the preferred addon for the semantic ID */
+ isPreferred: boolean;
+ /** Confidence score (0-1) for the match */
+ confidence: number;
+}
+
+/**
+ * Configuration for the submodel addon system
+ */
+export interface SubmodelAddonSystemConfig {
+ /** Whether to enable strict mode (throws on missing addons) */
+ strictMode: boolean;
+ /** Default fallback component for unknown submodels */
+ fallbackComponent?: ComponentType;
+ /** Maximum number of addons to cache */
+ maxCacheSize: number;
+ /** Whether to enable performance monitoring */
+ enablePerformanceMonitoring: boolean;
+}
diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/utils.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/utils.ts
new file mode 100644
index 00000000..aa5a4224
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/shared/utils.ts
@@ -0,0 +1,181 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+/**
+ * Generic utilities for working with Catena-X semantic models
+ */
+
+import { ParsedSemanticId } from '../types';
+import { compareVersions } from '../utils/version-utils';
+
+/**
+ * Semantic model constants
+ */
+export const SEMANTIC_MODEL_CONSTANTS = {
+ SAMM_PREFIX: 'urn:samm:',
+ BAMM_PREFIX: 'urn:bamm:',
+ NAMESPACE_PREFIX: 'io.catenax.',
+ SEPARATOR: ':',
+ FRAGMENT_SEPARATOR: '#',
+} as const;
+
+/**
+ * Creates a semantic ID string from its components
+ *
+ * @param namespace - The namespace (e.g., 'us_tariff_information')
+ * @param version - The version (e.g., '1.0.0')
+ * @param modelName - The model name (e.g., 'UsTariffInformation')
+ * @param useBAMM - Whether to use BAMM prefix instead of SAMM (default: false)
+ * @returns The complete semantic ID
+ */
+export function createSemanticId(namespace: string, version: string, modelName: string, useBAMM = false): string {
+ const prefix = useBAMM ? SEMANTIC_MODEL_CONSTANTS.BAMM_PREFIX : SEMANTIC_MODEL_CONSTANTS.SAMM_PREFIX;
+ return `${prefix}${SEMANTIC_MODEL_CONSTANTS.NAMESPACE_PREFIX}${namespace}${SEMANTIC_MODEL_CONSTANTS.SEPARATOR}${version}${SEMANTIC_MODEL_CONSTANTS.FRAGMENT_SEPARATOR}${modelName}`;
+}
+
+/**
+ * Parses a semantic ID string into its components
+ *
+ * @param semanticId - The semantic ID to parse
+ * @returns Parsed components or null if invalid
+ */
+export function parseSemanticId(semanticId: string): ParsedSemanticId | null {
+ try {
+ // Expected format: urn:(samm|bamm):io.catenax.namespace:version#ModelName
+ const regex = /^urn:(samm|bamm):io\.catenax\.([^:]+):(\d+)\.(\d+)\.(\d+)#(.+)$/;
+ const match = semanticId.match(regex);
+
+ if (!match) {
+ return null;
+ }
+
+ const [, prefix, namespace, major, minor, patch, modelName] = match;
+
+ // Extract the actual name from namespace (last part after dots)
+ const namespaceParts = namespace.split('.');
+ const name = namespaceParts[namespaceParts.length - 1];
+
+ return {
+ prefix: prefix as 'samm' | 'bamm',
+ namespace,
+ name,
+ version: {
+ major: parseInt(major, 10),
+ minor: parseInt(minor, 10),
+ patch: parseInt(patch, 10)
+ },
+ fragment: modelName,
+ originalId: semanticId,
+ };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Checks if a semantic ID belongs to a specific model (any version)
+ *
+ * @param semanticId - The semantic ID to check
+ * @param namespace - The expected namespace
+ * @param modelName - The expected model name
+ * @returns True if the semantic ID matches the namespace and model name
+ */
+export function isSemanticIdForModel(semanticId: string, namespace: string, modelName: string): boolean {
+ const parsed = parseSemanticId(semanticId);
+ return parsed !== null && parsed.namespace === namespace && parsed.fragment === modelName;
+}
+
+/**
+ * Checks if a semantic ID matches a specific version of a model
+ *
+ * @param semanticId - The semantic ID to check
+ * @param namespace - The expected namespace
+ * @param version - The expected version as SemanticVersion object
+ * @param modelName - The expected model name
+ * @returns True if the semantic ID matches all components
+ */
+export function isSemanticIdForModelVersion(
+ semanticId: string,
+ namespace: string,
+ version: { major: number; minor: number; patch: number },
+ modelName: string
+): boolean {
+ const parsed = parseSemanticId(semanticId);
+ return (
+ parsed !== null &&
+ parsed.namespace === namespace &&
+ parsed.version.major === version.major &&
+ parsed.version.minor === version.minor &&
+ parsed.version.patch === version.patch &&
+ parsed.fragment === modelName
+ );
+}
+
+/**
+ * Extracts the version from a semantic ID
+ *
+ * @param semanticId - The semantic ID to extract version from
+ * @returns The version string or null if invalid
+ */
+export function getSemanticIdVersion(semanticId: string): string | null {
+ const parsed = parseSemanticId(semanticId);
+ return parsed ? `${parsed.version.major}.${parsed.version.minor}.${parsed.version.patch}` : null;
+}
+
+/**
+ * Validates if a semantic ID follows the Catena-X SAMM format
+ *
+ * @param semanticId - The semantic ID to validate
+ * @returns True if the semantic ID is valid
+ */
+export function isValidSemanticId(semanticId: string): boolean {
+ return parseSemanticId(semanticId) !== null;
+}
+
+/**
+ * Gets all supported versions for a specific model from a list of semantic IDs
+ *
+ * @param semanticIds - Array of semantic IDs to filter
+ * @param namespace - The namespace to filter by
+ * @param modelName - The model name to filter by
+ * @returns Array of versions sorted in ascending order
+ */
+export function getSupportedVersionsForModel(
+ semanticIds: string[],
+ namespace: string,
+ modelName: string
+): string[] {
+ return semanticIds
+ .map(parseSemanticId)
+ .filter((parsed): parsed is ParsedSemanticId =>
+ parsed !== null &&
+ parsed.namespace === namespace &&
+ parsed.fragment === modelName
+ )
+ .map(parsed => `${parsed.version.major}.${parsed.version.minor}.${parsed.version.patch}`)
+ .sort((a, b) => {
+ // Convert string versions to SemanticVersion objects for comparison
+ const versionA = { major: parseInt(a.split('.')[0]), minor: parseInt(a.split('.')[1]), patch: parseInt(a.split('.')[2]) };
+ const versionB = { major: parseInt(b.split('.')[0]), minor: parseInt(b.split('.')[1]), patch: parseInt(b.split('.')[2]) };
+ return compareVersions(versionA, versionB);
+ });
+}
diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/types.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/types.ts
new file mode 100644
index 00000000..217f6f6c
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/types.ts
@@ -0,0 +1,120 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+import React from 'react';
+
+/**
+ * Semantic version representation
+ */
+export interface SemanticVersion {
+ major: number;
+ minor: number;
+ patch: number;
+}
+
+/**
+ * Version range for compatibility checking
+ */
+export interface VersionRange {
+ min?: SemanticVersion;
+ max?: SemanticVersion;
+ exact?: SemanticVersion;
+}
+
+/**
+ * Parsed semantic ID information
+ */
+export interface ParsedSemanticId {
+ prefix: 'samm' | 'bamm';
+ namespace: string;
+ name: string;
+ version: SemanticVersion;
+ fragment?: string;
+ originalId: string;
+}
+
+/**
+ * Base interface for submodel data
+ */
+export interface SubmodelData {
+ [key: string]: unknown;
+}
+
+/**
+ * Props passed to submodel add-on components
+ */
+export interface SubmodelAddonProps {
+ data: SubmodelData;
+ semanticId: string;
+ parsedSemanticId: ParsedSemanticId;
+ submodelId: string;
+ onExport?: (data: SubmodelData, filename: string) => void;
+ onShare?: (data: SubmodelData, title: string) => void;
+}
+
+/**
+ * Configuration for a submodel add-on
+ */
+export interface SubmodelAddonConfig {
+ /** Unique identifier for the add-on */
+ id: string;
+ /** Display name for the add-on */
+ name: string;
+ /** Description of what this add-on visualizes */
+ description: string;
+ /** Add-on version */
+ version: SemanticVersion;
+ /** Semantic ID namespace this add-on handles */
+ semanticNamespace: string;
+ /** Semantic ID name this add-on handles */
+ semanticName: string;
+ /** Supported semantic ID versions */
+ supportedVersions: VersionRange;
+ /** Priority for add-on selection (higher = more preferred) */
+ priority: number;
+ /** Icon component to display */
+ icon: React.ComponentType<{ fontSize?: string; color?: string }>;
+ /** The main visualization component */
+ component: React.ComponentType;
+ /** Whether this add-on can handle the given parsed semantic ID */
+ canHandle: (parsedSemanticId: ParsedSemanticId) => boolean;
+}
+
+/**
+ * Registry for all submodel add-ons
+ */
+export interface SubmodelAddonRegistry {
+ addons: Map;
+ register: (config: SubmodelAddonConfig) => void;
+ getAddon: (semanticId: string) => SubmodelAddonConfig | null;
+ getAllAddons: () => SubmodelAddonConfig[];
+ getCompatibleAddons: (parsedSemanticId: ParsedSemanticId) => SubmodelAddonConfig[];
+}
+
+/**
+ * Context for the submodel add-on system
+ */
+export interface SubmodelAddonContext {
+ registry: SubmodelAddonRegistry;
+ currentAddon: SubmodelAddonConfig | null;
+ setCurrentAddon: (addon: SubmodelAddonConfig | null) => void;
+}
diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/UsTariffInformationViewer.tsx b/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/UsTariffInformationViewer.tsx
new file mode 100644
index 00000000..28d8eeda
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/UsTariffInformationViewer.tsx
@@ -0,0 +1,554 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+import React from 'react';
+import {
+ Box,
+ Typography,
+ Card,
+ CardContent,
+ Chip,
+ Grid,
+ Alert,
+ List,
+ ListItem,
+ ListItemText
+} from '@mui/material';
+import { DataGrid, GridColDef } from '@mui/x-data-grid';
+import { PieChart } from '@mui/x-charts/PieChart';
+import InfoIcon from '@mui/icons-material/Info';
+import LocalShippingIcon from '@mui/icons-material/LocalShipping';
+import FactoryIcon from '@mui/icons-material/Factory';
+import VerifiedIcon from '@mui/icons-material/Verified';
+import MonetizationOnIcon from '@mui/icons-material/MonetizationOn';
+import PieChartIcon from '@mui/icons-material/PieChart';
+import CategoryIcon from '@mui/icons-material/Category';
+import LocationOnIcon from '@mui/icons-material/LocationOn';
+import GppGoodIcon from '@mui/icons-material/GppGood';
+import BusinessIcon from '@mui/icons-material/Business';
+import { SubmodelAddonProps } from '../shared/types';
+import { SubmodelAddonWrapper } from '../BaseAddon';
+import { UsTariffInformation } from './types';
+import { getCountryFlag } from '../utils/country-flags';
+
+/**
+ * Specialized viewer component for US Tariff Information submodels
+ */
+export const UsTariffInformationViewer: React.FC> = ({
+ data,
+ semanticId
+}) => {
+ const formatCurrency = (value: number, currency: string) => {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: currency
+ }).format(value);
+ };
+
+ const formatWeight = (weight: { value: number; unit: string }) => {
+ return `${weight.value} ${weight.unit}`;
+ };
+
+ // Distinct color palette for better visibility and differentiation
+ const distinctColorPalette = [
+ '#2196F3', // Blue
+ '#4CAF50', // Green
+ '#FF9800', // Orange
+ '#9C27B0', // Purple
+ '#F44336', // Red
+ '#00BCD4', // Cyan
+ '#795548', // Brown
+ '#607D8B', // Blue Grey
+ '#E91E63', // Pink
+ '#8BC34A', // Light Green
+ '#FF5722', // Deep Orange
+ '#3F51B5', // Indigo
+ ];
+
+ // Prepare pie chart data from material composition
+ const pieChartData = data.materialList.map((material, index) => ({
+ id: index,
+ value: material.value,
+ label: material.material.materialName,
+ color: distinctColorPalette[index % distinctColorPalette.length]
+ }));
+
+ // Prepare country origin share data
+ const countryOriginData = (() => {
+ const countryMap = new Map();
+
+ data.materialList.forEach(material => {
+ material.origin.forEach(origin => {
+ const country = origin.originCountry;
+ const percentage = origin.valuePercentage;
+ countryMap.set(country, (countryMap.get(country) || 0) + percentage);
+ });
+ });
+
+ return Array.from(countryMap.entries()).map(([country, percentage], index) => ({
+ id: index,
+ value: percentage,
+ label: `${getCountryFlag(country)} ${country}`,
+ color: distinctColorPalette[(index + 6) % distinctColorPalette.length] // Offset to use different colors
+ }));
+ })();
+
+ // Prepare data for DataGrid
+ const materialRows = data.materialList.map((material, index) => ({
+ id: index,
+ materialName: material.material.materialName,
+ classificationType: material.material.classificationType,
+ classificationId: material.material.classificationId,
+ referenceNumber: material.referenceNumber,
+ value: formatCurrency(material.value, material.currency),
+ originCountries: material.origin.map(origin => `${origin.originCountry} (${origin.valuePercentage}%)`).join(', '),
+ processingSteps: material.processing.length,
+ material: material // Keep full material object for complex rendering
+ }));
+
+ const materialColumns: GridColDef[] = [
+ {
+ field: 'materialName',
+ headerName: 'Material',
+ flex: 1,
+ minWidth: 200,
+ renderCell: (params) => (
+
+
+ {params.row.materialName}
+
+
+ {params.row.classificationType}: {params.row.classificationId}
+
+
+ )
+ },
+ {
+ field: 'referenceNumber',
+ headerName: 'Reference',
+ width: 140
+ },
+ {
+ field: 'value',
+ headerName: 'Value',
+ width: 120
+ },
+ {
+ field: 'originCountries',
+ headerName: 'Origin Countries',
+ flex: 1,
+ minWidth: 250,
+ renderCell: (params) => (
+
+ {params.row.material.origin.map((origin: { originCountry: string; valuePercentage: number }, idx: number) => (
+
+ ))}
+
+ )
+ },
+ {
+ field: 'processingSteps',
+ headerName: 'Processing Steps',
+ width: 130,
+ renderCell: (params) => (
+
+ {params.value} step(s)
+
+ )
+ }
+ ];
+
+ return (
+
+
+ {/* Part Information */}
+
+
+
+
+ Part Information
+
+
+
+ Part ID
+ {data.partId}
+
+
+ Part Name
+ {data.partName}
+
+
+ Description
+ {data.partDescription}
+
+
+ Weight
+ {formatWeight(data.partWeight)}
+
+
+ Vehicle System
+ {data.partUsage.vehicleSystem}
+
+
+ Vehicle Subassembly
+ {data.partUsage.vehicleSubassembly}
+
+
+ OEM Part References
+
+ {data.partUsage.oemPartRef.map((ref, index) => (
+
+ ))}
+
+
+
+
+
+
+ {/* Tariff Information */}
+
+
+
+
+ Tariff Information
+
+
+
+
+
+ HTS Code
+
+
+ {data.tariff.htsCode}
+
+
+
+ Coding System
+ {data.tariff.htsCodingSystem}
+
+
+ HTS Description
+ {data.tariff.htsDescription}
+
+
+ Country of Import
+ {data.tariff.countryOfImport}
+
+
+ Country of Export
+ {data.tariff.countryOfExport}
+
+
+ Incoterms
+ {data.tariff.incoterms}
+
+
+ Declared Customs Value
+
+ {formatCurrency(data.tariff.declaredCustomsValue.value, data.tariff.declaredCustomsValue.currency)}
+
+
+ {data.tariff.dutyRateNote && (
+
+ Duty Rate Note
+
+ {data.tariff.dutyRateNote}
+
+
+ )}
+
+
+
+
+ {/* Material Value Distribution Chart */}
+
+
+
+
+ Material Analysis
+
+
+ {/* Material Value Distribution */}
+
+
+
+
+ Value Distribution by Material
+
+
+
+
+
+
+
+
+ {/* Country Origin Share */}
+
+
+
+
+
+ Country Origin Share (%)
+
+
+
+
+
+
+
+
+
+
+
+ {/* Material Composition Details Table */}
+
+
+
+
+ Material Composition Details
+
+
+ 'auto'}
+ sx={{
+ '& .MuiDataGrid-cell': {
+ py: 1
+ }
+ }}
+ />
+
+
+
+
+ {/* Compliance Information */}
+
+
+
+
+ Compliance Information
+
+
+
+
+
+ RoHS Compliance
+
+
+
+ {data.compliance.rohs.exemptions.length > 0 && (
+
+ {data.compliance.rohs.exemptions.length} exemption(s)
+
+ )}
+
+
+
+ REACH SVHC Content
+
+ {data.compliance.reach.svhcContentWppm} wppm
+
+
+
+ ISO Certificates
+
+ {data.compliance.isoCertificates.map((cert, index) => (
+
+ ))}
+
+
+
+
+
+
+ {/* Supply Chain */}
+
+
+
+
+ Supply Chain Information
+
+
+
+
+
+ Manufacturer
+
+
+ {data.supplyChain.manufacturer}
+
+
+
+
+
+ Final Assembly
+
+
+ {getCountryFlag(data.supplyChain.finalAssembly)} {data.supplyChain.finalAssembly}
+
+
+
+ Batch Number
+
+ {data.supplyChain.batchNumber}
+
+
+
+ Lot Code Marking
+
+ {data.supplyChain.traceability.lotCodeMarking}
+
+
+
+ Date Code Format
+
+ {data.supplyChain.traceability.dateCodeFormat}
+
+
+
+
+
+
+ {/* Totals Check */}
+
+
+
+ Totals Verification
+
+
+
+ Sum of Material Weights
+
+ {data.totalsCheck.sumOfMaterialWeights_g} g
+
+
+
+ Sum of Origin Value Percentages
+
+ {data.totalsCheck.sumOfOriginValuePercentages}%
+
+
+
+
+
+
+ {/* Notes */}
+ {data.notes && data.notes.length > 0 && (
+
+
+
+ Additional Notes
+
+
+ {data.notes.map((note, index) => (
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ );
+};
diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/addon.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/addon.ts
new file mode 100644
index 00000000..35462bad
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/addon.ts
@@ -0,0 +1,49 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+import { VersionedSubmodelAddon } from '../shared/types';
+import {
+ UsTariffInformation,
+ US_TARIFF_INFORMATION_NAMESPACE,
+ US_TARIFF_INFORMATION_MODEL_NAME,
+ US_TARIFF_INFORMATION_SEMANTIC_IDS,
+ isUsTariffInformation,
+} from './types';
+import { UsTariffInformationViewer } from '.';
+
+/**
+ * US Tariff Information submodel addon configuration
+ *
+ * This addon provides specialized visualization for US Tariff Information submodels,
+ * displaying detailed information about parts, materials, tariffs, and compliance data.
+ */
+export const usTariffInformationAddon: VersionedSubmodelAddon = {
+ id: 'us-tariff-information',
+ name: 'US Tariff Information',
+ description: 'Specialized visualization for US Tariff Information submodels including part details, material composition, tariff codes, and compliance information.',
+ namespace: US_TARIFF_INFORMATION_NAMESPACE,
+ modelName: US_TARIFF_INFORMATION_MODEL_NAME,
+ supportedSemanticIds: Object.values(US_TARIFF_INFORMATION_SEMANTIC_IDS),
+ priority: 10, // High priority for exact matches
+ isValidData: isUsTariffInformation,
+ component: UsTariffInformationViewer,
+};
diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/index.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/index.ts
new file mode 100644
index 00000000..87eda4fa
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/index.ts
@@ -0,0 +1,23 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+export { UsTariffInformationViewer } from './UsTariffInformationViewer';
diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/types.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/types.ts
new file mode 100644
index 00000000..df8b7a68
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/us-tariff-information/types.ts
@@ -0,0 +1,281 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+/**
+ * Type definitions for US Tariff Information submodel
+ * Semantic Model: urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation
+ */
+
+import { isSemanticIdForModel, createSemanticId } from '../shared/utils';
+
+// Constants for US Tariff Information semantic model
+export const US_TARIFF_INFORMATION_NAMESPACE = 'us_tariff_information';
+export const US_TARIFF_INFORMATION_MODEL_NAME = 'UsTariffInformation';
+
+/**
+ * Supported semantic IDs for US Tariff Information submodel
+ */
+export const US_TARIFF_INFORMATION_SEMANTIC_IDS = {
+ V1_0_0: createSemanticId(US_TARIFF_INFORMATION_NAMESPACE, '1.0.0', US_TARIFF_INFORMATION_MODEL_NAME),
+ // Future versions will be added here as they become available:
+ // V1_1_0: createSemanticId(US_TARIFF_INFORMATION_NAMESPACE, '1.1.0', US_TARIFF_INFORMATION_MODEL_NAME),
+ // V2_0_0: createSemanticId(US_TARIFF_INFORMATION_NAMESPACE, '2.0.0', US_TARIFF_INFORMATION_MODEL_NAME),
+} as const;
+
+export interface Weight {
+ value: number;
+ unit: string;
+}
+
+export interface Currency {
+ value: number;
+ currency: string;
+}
+
+export interface PartUsage {
+ vehicleSystem: string;
+ vehicleSubassembly: string;
+ oemPartRef: string[];
+}
+
+export interface TariffInfo {
+ htsCode: string;
+ htsCodingSystem: string;
+ htsDescription: string;
+ countryOfImport: string;
+ declaredCustomsValue: Currency;
+ incoterms: string;
+ countryOfExport: string;
+ dutyRateNote?: string;
+}
+
+export interface MaterialClassification {
+ classificationType: string;
+ classificationId: string;
+ materialName: string;
+}
+
+export interface Origin {
+ originCountry: string;
+ valuePercentage: number;
+ originWeight: Weight;
+}
+
+export interface Processing {
+ processingCountry: string;
+ processingId: number;
+ processingType: string;
+ successor?: Array<{ successorId: number }>;
+ certificateId?: string;
+}
+
+export interface SurfaceTreatment {
+ type: string;
+ standard: string;
+ hexavalentChromiumFree: boolean;
+}
+
+export interface Material {
+ material: MaterialClassification;
+ referenceNumber: string;
+ origin: Origin[];
+ processing: Processing[];
+ surfaceTreatment?: SurfaceTreatment[];
+ currency: string;
+ value: number;
+}
+
+export interface Compliance {
+ rohs: {
+ compliant: boolean;
+ exemptions: string[];
+ };
+ reach: {
+ svhcContentWppm: number;
+ };
+ isoCertificates: string[];
+}
+
+export interface SupplyChain {
+ manufacturer: string;
+ finalAssembly: string;
+ batchNumber: string;
+ traceability: {
+ lotCodeMarking: string;
+ dateCodeFormat: string;
+ };
+}
+
+export interface TotalsCheck {
+ sumOfMaterialWeights_g: number;
+ sumOfOriginValuePercentages: number;
+}
+
+// Version 1.0.0 types for UsTariffInformation
+export interface UsTariffInformationV1_0_0 {
+ partId: string;
+ partName: string;
+ partDescription: string;
+ partWeight: {
+ value: number;
+ unit: string;
+ };
+ partUsage: {
+ vehicleSystem: string;
+ vehicleSubassembly: string;
+ oemPartRef: string[];
+ };
+ tariff: {
+ htsCode: string;
+ htsCodingSystem: string;
+ htsDescription: string;
+ countryOfImport: string;
+ declaredCustomsValue: {
+ value: number;
+ currency: string;
+ };
+ incoterms: string;
+ countryOfExport: string;
+ dutyRateNote: string;
+ };
+ materialList: Array<{
+ material: {
+ classificationType: string;
+ classificationId: string;
+ materialName: string;
+ };
+ referenceNumber: string;
+ origin: Array<{
+ originCountry: string;
+ valuePercentage: number;
+ originWeight: {
+ value: number;
+ unit: string;
+ };
+ }>;
+ processing: Array<{
+ processingCountry: string;
+ processingId: number;
+ processingType: string;
+ successor?: Array<{
+ successorId: number;
+ }>;
+ certificateId?: string;
+ }>;
+ surfaceTreatment?: Array<{
+ type: string;
+ standard: string;
+ hexavalentChromiumFree: boolean;
+ }>;
+ currency: string;
+ value: number;
+ }>;
+ compliance: {
+ rohs: {
+ compliant: boolean;
+ exemptions: string[];
+ };
+ reach: {
+ svhcContentWppm: number;
+ };
+ isoCertificates: string[];
+ };
+ supplyChain: {
+ manufacturer: string;
+ finalAssembly: string;
+ batchNumber: string;
+ traceability: {
+ lotCodeMarking: string;
+ dateCodeFormat: string;
+ };
+ };
+ totalsCheck: {
+ sumOfMaterialWeights_g: number;
+ sumOfOriginValuePercentages: number;
+ };
+ notes: string[];
+}
+
+// Union type for all supported versions (currently only 1.0.0)
+export type UsTariffInformation = UsTariffInformationV1_0_0;
+
+// When new versions are released, add them here:
+// export type UsTariffInformation =
+// | UsTariffInformationV1_0_0
+// | UsTariffInformationV1_1_0
+// | UsTariffInformationV2_0_0;
+
+/**
+ * Type guards for US Tariff Information submodel versions
+ */
+
+/**
+ * Type guard for US Tariff Information v1.0.0
+ *
+ * @param semanticId - The semantic ID to validate
+ * @param data - The data to validate
+ * @returns True if data matches UsTariffInformationV1_0_0 structure
+ */
+export function isUsTariffInformationV1_0_0(semanticId: string, data: unknown): data is UsTariffInformationV1_0_0 {
+ return (
+ semanticId === US_TARIFF_INFORMATION_SEMANTIC_IDS.V1_0_0 &&
+ typeof data === 'object' &&
+ data !== null &&
+ 'partId' in data &&
+ typeof (data as Record).partId === 'string' &&
+ 'partName' in data &&
+ typeof (data as Record).partName === 'string' &&
+ 'partDescription' in data &&
+ typeof (data as Record).partDescription === 'string' &&
+ 'partWeight' in data &&
+ typeof (data as Record).partWeight === 'object' &&
+ 'materialList' in data &&
+ Array.isArray((data as Record).materialList) &&
+ 'tariff' in data &&
+ typeof (data as Record).tariff === 'object'
+ );
+}
+
+/**
+ * Generic type guard for any supported US Tariff Information version
+ *
+ * @param semanticId - The semantic ID to validate
+ * @param data - The data to validate
+ * @returns True if data matches any supported UsTariffInformation version
+ */
+export function isUsTariffInformation(semanticId: string, data: unknown): data is UsTariffInformation {
+ return isUsTariffInformationV1_0_0(semanticId, data);
+ // When new versions are added, extend this logic:
+ // return isUsTariffInformationV1_0_0(semanticId, data) ||
+ // isUsTariffInformationV1_1_0(semanticId, data) ||
+ // isUsTariffInformationV2_0_0(semanticId, data);
+}
+
+/**
+ * Checks if a semantic ID is for US Tariff Information (any version)
+ *
+ * @param semanticId - The semantic ID to check
+ * @returns True if the semantic ID is for US Tariff Information
+ */
+export function isUsTariffInformationSemanticId(semanticId: string): boolean {
+ return isSemanticIdForModel(semanticId, US_TARIFF_INFORMATION_NAMESPACE, US_TARIFF_INFORMATION_MODEL_NAME);
+}
diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/utils/country-flags.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/utils/country-flags.ts
new file mode 100644
index 00000000..0ff8ab6b
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/utils/country-flags.ts
@@ -0,0 +1,171 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+/**
+ * Comprehensive mapping of country codes and names to their flag emojis
+ */
+const COUNTRY_FLAG_MAP: Record = {
+ // North America
+ 'US': '🇺🇸', 'USA': '🇺🇸', 'United States': '🇺🇸', 'United States of America': '🇺🇸',
+ 'CA': '🇨🇦', 'CAN': '🇨🇦', 'Canada': '🇨🇦',
+ 'MX': '🇲🇽', 'MEX': '🇲🇽', 'Mexico': '🇲🇽',
+
+ // Europe - Western
+ 'DE': '🇩🇪', 'DEU': '🇩🇪', 'Germany': '🇩🇪', 'Deutschland': '🇩🇪',
+ 'FR': '🇫🇷', 'FRA': '🇫🇷', 'France': '🇫🇷',
+ 'IT': '🇮🇹', 'ITA': '🇮🇹', 'Italy': '🇮🇹',
+ 'GB': '🇬🇧', 'GBR': '🇬🇧', 'United Kingdom': '🇬🇧', 'UK': '🇬🇧', 'Britain': '🇬🇧',
+ 'ES': '🇪🇸', 'ESP': '🇪🇸', 'Spain': '🇪🇸',
+ 'NL': '🇳🇱', 'NLD': '🇳🇱', 'Netherlands': '🇳🇱', 'Holland': '🇳🇱',
+ 'BE': '🇧🇪', 'BEL': '🇧🇪', 'Belgium': '🇧🇪',
+ 'CH': '🇨🇭', 'CHE': '🇨🇭', 'Switzerland': '🇨🇭',
+ 'AT': '🇦🇹', 'AUT': '🇦🇹', 'Austria': '🇦🇹',
+ 'PT': '🇵🇹', 'PRT': '🇵🇹', 'Portugal': '🇵🇹',
+ 'IE': '🇮🇪', 'IRL': '🇮🇪', 'Ireland': '🇮🇪',
+ 'LU': '🇱🇺', 'LUX': '🇱🇺', 'Luxembourg': '🇱🇺',
+
+ // Europe - Nordic
+ 'SE': '🇸🇪', 'SWE': '🇸🇪', 'Sweden': '🇸🇪',
+ 'NO': '🇳🇴', 'NOR': '🇳🇴', 'Norway': '🇳🇴',
+ 'DK': '🇩🇰', 'DNK': '🇩🇰', 'Denmark': '🇩🇰',
+ 'FI': '🇫🇮', 'FIN': '🇫🇮', 'Finland': '🇫🇮',
+
+ // Europe - Eastern
+ 'PL': '🇵🇱', 'POL': '🇵🇱', 'Poland': '🇵🇱',
+ 'CZ': '🇨🇿', 'CZE': '🇨🇿', 'Czech Republic': '🇨🇿', 'Czechia': '🇨🇿',
+ 'HU': '🇭🇺', 'HUN': '🇭🇺', 'Hungary': '🇭🇺',
+ 'RO': '🇷🇴', 'ROU': '🇷🇴', 'Romania': '🇷🇴',
+ 'SK': '🇸🇰', 'SVK': '🇸🇰', 'Slovakia': '🇸🇰',
+ 'SI': '🇸🇮', 'SVN': '🇸🇮', 'Slovenia': '🇸🇮',
+ 'HR': '🇭🇷', 'HRV': '🇭🇷', 'Croatia': '🇭🇷',
+ 'BG': '🇧🇬', 'BGR': '🇧🇬', 'Bulgaria': '🇧🇬',
+ 'GR': '🇬🇷', 'GRC': '🇬🇷', 'Greece': '🇬🇷',
+ 'EE': '🇪🇪', 'EST': '🇪🇪', 'Estonia': '🇪🇪',
+ 'LV': '🇱🇻', 'LVA': '🇱🇻', 'Latvia': '🇱🇻',
+ 'LT': '🇱🇹', 'LTU': '🇱🇹', 'Lithuania': '🇱🇹',
+
+ // Europe - Mediterranean
+ 'MT': '🇲🇹', 'MLT': '🇲🇹', 'Malta': '🇲🇹',
+ 'CY': '🇨🇾', 'CYP': '🇨🇾', 'Cyprus': '🇨🇾',
+
+ // Asia - East
+ 'CN': '🇨🇳', 'CHN': '🇨🇳', 'China': '🇨🇳', 'People\'s Republic of China': '🇨🇳',
+ 'JP': '🇯🇵', 'JPN': '🇯🇵', 'Japan': '🇯🇵',
+ 'KR': '🇰🇷', 'KOR': '🇰🇷', 'South Korea': '🇰🇷', 'Korea': '🇰🇷',
+ 'TW': '🇹🇼', 'TWN': '🇹🇼', 'Taiwan': '🇹🇼',
+
+ // Asia - Southeast
+ 'SG': '🇸🇬', 'SGP': '🇸🇬', 'Singapore': '🇸🇬',
+ 'MY': '🇲🇾', 'MYS': '🇲🇾', 'Malaysia': '🇲🇾',
+ 'TH': '🇹🇭', 'THA': '🇹🇭', 'Thailand': '🇹🇭',
+ 'VN': '🇻🇳', 'VNM': '🇻🇳', 'Vietnam': '🇻🇳',
+ 'PH': '🇵🇭', 'PHL': '🇵🇭', 'Philippines': '🇵🇭',
+ 'ID': '🇮🇩', 'IDN': '🇮🇩', 'Indonesia': '🇮🇩',
+
+ // Asia - South
+ 'IN': '🇮🇳', 'IND': '🇮🇳', 'India': '🇮🇳',
+
+ // Asia - West/Middle East
+ 'RU': '🇷🇺', 'RUS': '🇷🇺', 'Russia': '🇷🇺', 'Russian Federation': '🇷🇺',
+ 'TR': '🇹🇷', 'TUR': '🇹🇷', 'Turkey': '🇹🇷', 'Türkiye': '🇹🇷',
+ 'IL': '🇮🇱', 'ISR': '🇮🇱', 'Israel': '🇮🇱',
+ 'SA': '🇸🇦', 'SAU': '🇸🇦', 'Saudi Arabia': '🇸🇦',
+ 'AE': '🇦🇪', 'ARE': '🇦🇪', 'UAE': '🇦🇪', 'United Arab Emirates': '🇦🇪',
+
+ // Africa
+ 'ZA': '🇿🇦', 'ZAF': '🇿🇦', 'South Africa': '🇿🇦',
+ 'EG': '🇪🇬', 'EGY': '🇪🇬', 'Egypt': '🇪🇬',
+
+ // Oceania
+ 'AU': '🇦🇺', 'AUS': '🇦🇺', 'Australia': '🇦🇺',
+ 'NZ': '🇳🇿', 'NZL': '🇳🇿', 'New Zealand': '🇳🇿',
+
+ // South America
+ 'BR': '🇧🇷', 'BRA': '🇧🇷', 'Brazil': '🇧🇷',
+ 'AR': '🇦🇷', 'ARG': '🇦🇷', 'Argentina': '🇦🇷',
+ 'CL': '🇨🇱', 'CHL': '🇨🇱', 'Chile': '🇨🇱',
+ 'CO': '🇨🇴', 'COL': '🇨🇴', 'Colombia': '🇨🇴',
+ 'PE': '🇵🇪', 'PER': '🇵🇪', 'Peru': '🇵🇪',
+ 'VE': '🇻🇪', 'VEN': '🇻🇪', 'Venezuela': '🇻🇪',
+};
+
+/**
+ * Gets the flag emoji for a given country code or name
+ *
+ * @param countryCode - Country code (ISO 2/3 letter) or full country name
+ * @returns Flag emoji string, or default flag if not found
+ *
+ * @example
+ * ```typescript
+ * getCountryFlag('US') // Returns '🇺🇸'
+ * getCountryFlag('Germany') // Returns '🇩🇪'
+ * getCountryFlag('unknown') // Returns '🏳️'
+ * ```
+ */
+export const getCountryFlag = (countryCode: string): string => {
+ // Handle empty or invalid input
+ if (!countryCode || typeof countryCode !== 'string') {
+ return '🏳️';
+ }
+
+ // Try exact match first
+ if (COUNTRY_FLAG_MAP[countryCode]) {
+ return COUNTRY_FLAG_MAP[countryCode];
+ }
+
+ // Try case-insensitive search
+ const lowerCode = countryCode.toLowerCase();
+ for (const [key, flag] of Object.entries(COUNTRY_FLAG_MAP)) {
+ if (key.toLowerCase() === lowerCode) {
+ return flag;
+ }
+ }
+
+ // Try partial match for longer country names
+ for (const [key, flag] of Object.entries(COUNTRY_FLAG_MAP)) {
+ if (countryCode.toLowerCase().includes(key.toLowerCase()) ||
+ key.toLowerCase().includes(countryCode.toLowerCase())) {
+ return flag;
+ }
+ }
+
+ return '🏳️'; // Default flag for unknown countries
+};
+
+/**
+ * Gets all available country codes and names that have flag mappings
+ *
+ * @returns Array of country identifiers
+ */
+export const getAvailableCountries = (): string[] => {
+ return Object.keys(COUNTRY_FLAG_MAP);
+};
+
+/**
+ * Checks if a country has a flag mapping available
+ *
+ * @param countryCode - Country code or name to check
+ * @returns True if flag mapping exists
+ */
+export const hasCountryFlag = (countryCode: string): boolean => {
+ return getCountryFlag(countryCode) !== '🏳️';
+};
diff --git a/ichub-frontend/src/features/part-discovery/components/submodel-addons/utils/version-utils.ts b/ichub-frontend/src/features/part-discovery/components/submodel-addons/utils/version-utils.ts
new file mode 100644
index 00000000..c82f902c
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/components/submodel-addons/utils/version-utils.ts
@@ -0,0 +1,161 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+import { SemanticVersion, VersionRange, ParsedSemanticId } from '../types';
+
+/**
+ * Parse a semantic ID string into its components
+ * Format: urn:(samm|bamm):namespace:version#fragment
+ * Examples:
+ * - urn:samm:io.catenax.us_tariff_information:1.0.0#UsTariffInformation
+ * - urn:bamm:io.catenax.single_level_bom_as_built:3.0.0#SingleLevelBomAsBuilt
+ */
+export function parseSemanticId(semanticId: string): ParsedSemanticId | null {
+ try {
+ // Regular expression to parse URN SAMM/BAMM format
+ const pattern = /^urn:(samm|bamm):([^:]+):(\d+)\.(\d+)\.(\d+)(?:#(.+))?$/;
+ const match = semanticId.match(pattern);
+
+ if (!match) {
+ console.warn(`Invalid semantic ID format: ${semanticId}`);
+ return null;
+ }
+
+ const [, prefix, namespace, major, minor, patch, fragment] = match;
+
+ // Extract the actual name from namespace (last part after dots)
+ const namespaceParts = namespace.split('.');
+ const name = namespaceParts[namespaceParts.length - 1];
+
+ return {
+ prefix: prefix as 'samm' | 'bamm',
+ namespace,
+ name,
+ version: {
+ major: parseInt(major, 10),
+ minor: parseInt(minor, 10),
+ patch: parseInt(patch, 10)
+ },
+ fragment,
+ originalId: semanticId
+ };
+ } catch (error) {
+ console.error(`Error parsing semantic ID: ${semanticId}`, error);
+ return null;
+ }
+}
+
+/**
+ * Compare two semantic versions
+ * Returns: -1 if a < b, 0 if a === b, 1 if a > b
+ */
+export function compareVersions(a: SemanticVersion, b: SemanticVersion): number {
+ if (a.major !== b.major) {
+ return a.major - b.major;
+ }
+ if (a.minor !== b.minor) {
+ return a.minor - b.minor;
+ }
+ return a.patch - b.patch;
+}
+
+/**
+ * Check if a version is within a given range
+ */
+export function isVersionInRange(version: SemanticVersion, range: VersionRange): boolean {
+ // Check exact version match first
+ if (range.exact) {
+ return compareVersions(version, range.exact) === 0;
+ }
+
+ // Check minimum version
+ if (range.min && compareVersions(version, range.min) < 0) {
+ return false;
+ }
+
+ // Check maximum version
+ if (range.max && compareVersions(version, range.max) > 0) {
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Check if two versions are compatible (same major version)
+ */
+export function areVersionsCompatible(a: SemanticVersion, b: SemanticVersion): boolean {
+ return a.major === b.major;
+}
+
+/**
+ * Format a semantic version as a string
+ */
+export function formatVersion(version: SemanticVersion): string {
+ return `${version.major}.${version.minor}.${version.patch}`;
+}
+
+/**
+ * Format a version range as a string
+ */
+export function formatVersionRange(range: VersionRange): string {
+ if (range.exact) {
+ return formatVersion(range.exact);
+ }
+
+ const parts: string[] = [];
+ if (range.min) {
+ parts.push(`>= ${formatVersion(range.min)}`);
+ }
+ if (range.max) {
+ parts.push(`<= ${formatVersion(range.max)}`);
+ }
+
+ return parts.length > 0 ? parts.join(' && ') : '*';
+}
+
+/**
+ * Create a version range for backward compatibility
+ * Supports same major version with any minor/patch
+ */
+export function createCompatibleVersionRange(version: SemanticVersion): VersionRange {
+ return {
+ min: { major: version.major, minor: 0, patch: 0 },
+ max: { major: version.major, minor: Number.MAX_SAFE_INTEGER, patch: Number.MAX_SAFE_INTEGER }
+ };
+}
+
+/**
+ * Create a version range for exact version match
+ */
+export function createExactVersionRange(version: SemanticVersion): VersionRange {
+ return {
+ exact: version
+ };
+}
+
+/**
+ * Create a version range for a specific version range
+ */
+export function createVersionRange(min?: SemanticVersion, max?: SemanticVersion): VersionRange {
+ return { min, max };
+}
diff --git a/ichub-frontend/src/features/part-discovery/data-converters.ts b/ichub-frontend/src/features/part-discovery/data-converters.ts
new file mode 100644
index 00000000..d89e11f2
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/data-converters.ts
@@ -0,0 +1,78 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import { AASData, getAASDataSummary } from './utils';
+import { PartCardData, SerializedPartData } from './types';
+
+/**
+ * Converts AAS data to Part Card format for catalog view
+ */
+export const convertToPartCards = (
+ shells: AASData[],
+ shellToDtrMap?: Map
+): PartCardData[] => {
+ return shells.map(shell => {
+ const summary = getAASDataSummary(shell);
+ const dtrIndex = shellToDtrMap?.get(shell.id);
+ return {
+ id: shell.id,
+ manufacturerId: summary.manufacturerId || 'Unknown',
+ manufacturerPartId: summary.manufacturerPartId || 'Unknown',
+ customerPartId: summary.customerPartId || undefined,
+ name: `${summary.manufacturerPartId}`,
+ category: summary.customerPartId || undefined,
+ digitalTwinType: summary.digitalTwinType || 'Unknown',
+ globalAssetId: shell.globalAssetId,
+ submodelCount: summary.submodelCount,
+ dtrIndex,
+ idShort: shell.idShort, // Include idShort from AAS data
+ rawTwinData: shell
+ };
+ });
+};
+
+/**
+ * Converts AAS data to Serialized Parts format for instance view
+ */
+export const convertToSerializedParts = (
+ shells: AASData[],
+ shellToDtrMap?: Map
+): SerializedPartData[] => {
+ return shells.map(shell => {
+ const summary = getAASDataSummary(shell);
+ const dtrIndex = shellToDtrMap?.get(shell.id);
+ return {
+ id: shell.id,
+ globalAssetId: shell.globalAssetId,
+ aasId: shell.id, // AAS Shell ID
+ manufacturerId: summary.manufacturerId || 'Unknown',
+ manufacturerPartId: summary.manufacturerPartId || 'Unknown',
+ customerPartId: summary.customerPartId || undefined,
+ partInstanceId: summary.partInstanceId || undefined,
+ digitalTwinType: summary.digitalTwinType || 'Unknown',
+ submodelCount: summary.submodelCount,
+ dtrIndex,
+ idShort: shell.idShort, // Include idShort from AAS data
+ rawTwinData: shell
+ };
+ });
+};
diff --git a/ichub-frontend/src/features/part-discovery/dtr-utils.ts b/ichub-frontend/src/features/part-discovery/dtr-utils.ts
new file mode 100644
index 00000000..e59af7de
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/dtr-utils.ts
@@ -0,0 +1,75 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+/**
+ * Creates a map from shell ID to DTR index for efficient lookup
+ */
+export const createShellToDtrMap = (
+ dtrs: Array & { shells?: string[] }>
+): Map => {
+ const shellToDtrMap = new Map();
+ dtrs.forEach((dtr, dtrIndex) => {
+ if (dtr.shells && Array.isArray(dtr.shells)) {
+ dtr.shells.forEach((shellId: string) => {
+ shellToDtrMap.set(shellId, dtrIndex);
+ });
+ }
+ });
+ return shellToDtrMap;
+};
+
+interface DtrColor {
+ bg: string;
+ color: string;
+ light: string;
+ border: string;
+}
+
+/**
+ * Gets consistent colors for DTR identifiers with support for many DTRs
+ */
+export const getDtrColor = (dtrIndex: number): DtrColor => {
+ const baseColors = [
+ { bg: 'rgba(76, 175, 80, 0.9)', color: 'white', light: 'rgba(76, 175, 80, 0.1)', border: 'rgba(76, 175, 80, 0.3)' }, // Green
+ { bg: 'rgba(33, 150, 243, 0.9)', color: 'white', light: 'rgba(33, 150, 243, 0.1)', border: 'rgba(33, 150, 243, 0.3)' }, // Blue
+ { bg: 'rgba(255, 152, 0, 0.9)', color: 'white', light: 'rgba(255, 152, 0, 0.1)', border: 'rgba(255, 152, 0, 0.3)' }, // Orange
+ { bg: 'rgba(156, 39, 176, 0.9)', color: 'white', light: 'rgba(156, 39, 176, 0.1)', border: 'rgba(156, 39, 176, 0.3)' }, // Purple
+ { bg: 'rgba(244, 67, 54, 0.9)', color: 'white', light: 'rgba(244, 67, 54, 0.1)', border: 'rgba(244, 67, 54, 0.3)' }, // Red
+ { bg: 'rgba(0, 188, 212, 0.9)', color: 'white', light: 'rgba(0, 188, 212, 0.1)', border: 'rgba(0, 188, 212, 0.3)' }, // Cyan
+ { bg: 'rgba(139, 195, 74, 0.9)', color: 'white', light: 'rgba(139, 195, 74, 0.1)', border: 'rgba(139, 195, 74, 0.3)' }, // Light Green
+ { bg: 'rgba(121, 85, 72, 0.9)', color: 'white', light: 'rgba(121, 85, 72, 0.1)', border: 'rgba(121, 85, 72, 0.3)' }, // Brown
+ ];
+
+ const colorIndex = dtrIndex % baseColors.length;
+ const variation = Math.floor(dtrIndex / baseColors.length);
+
+ // For DTRs beyond 8, add opacity variations to distinguish them
+ const baseColor = baseColors[colorIndex];
+ const opacity = Math.max(0.7, 1 - (variation * 0.1)); // Gradually reduce opacity
+
+ return {
+ bg: baseColor.bg.replace('0.9)', `${opacity})`),
+ color: baseColor.color,
+ light: baseColor.light,
+ border: baseColor.border
+ };
+};
diff --git a/ichub-frontend/src/features/part-discovery/hooks/usePartsDiscoverySearch.ts b/ichub-frontend/src/features/part-discovery/hooks/usePartsDiscoverySearch.ts
new file mode 100644
index 00000000..131238cc
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/hooks/usePartsDiscoverySearch.ts
@@ -0,0 +1,389 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import { useState } from 'react';
+import {
+ discoverShellsWithCustomQuery,
+ discoverSingleShell,
+ ShellDiscoveryPaginator,
+ SingleShellDiscoveryResponse
+} from '../api';
+import {
+ ShellDiscoveryResponse,
+} from '../utils';
+import { convertToPartCards, convertToSerializedParts } from '../data-converters';
+import { createShellToDtrMap } from '../dtr-utils';
+import {
+ PartCardData,
+ SerializedPartData,
+ SearchFilters,
+ PartType,
+ PaginationSettings
+} from '../types';
+
+export const usePartsDiscoverySearch = () => {
+ // Results and pagination
+ const [partTypeCards, setPartTypeCards] = useState([]);
+ const [serializedParts, setSerializedParts] = useState([]);
+ const [currentResponse, setCurrentResponse] = useState(null);
+ const [paginator, setPaginator] = useState(null);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(0);
+
+ // Single twin search
+ const [singleTwinResult, setSingleTwinResult] = useState(null);
+
+ // Loading and error states
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [hasSearched, setHasSearched] = useState(false);
+
+ // Pagination loading states
+ const [isLoadingNext, setIsLoadingNext] = useState(false);
+ const [isLoadingPrevious, setIsLoadingPrevious] = useState(false);
+
+ const resetSearch = () => {
+ setHasSearched(false);
+ setCurrentResponse(null);
+ setPaginator(null);
+ setPartTypeCards([]);
+ setSerializedParts([]);
+ setCurrentPage(1);
+ setTotalPages(0);
+ setError(null);
+ setSingleTwinResult(null);
+ setIsLoadingNext(false);
+ setIsLoadingPrevious(false);
+ };
+
+ const clearError = () => {
+ setError(null);
+ };
+
+ const performDiscoverySearch = async (
+ bpnl: string,
+ partType: PartType,
+ filters: SearchFilters,
+ paginationSettings: PaginationSettings
+ ) => {
+ // Validate custom limit
+ if (paginationSettings.isCustomLimit) {
+ if (!paginationSettings.customLimit.trim()) {
+ setError('Please enter a custom limit or select a predefined option');
+ return;
+ }
+ const customLimitNum = parseInt(paginationSettings.customLimit);
+ if (isNaN(customLimitNum) || customLimitNum < 1 || customLimitNum > 1000) {
+ setError('Custom limit must be a number between 1 and 1000');
+ return;
+ }
+ }
+
+ setIsLoading(true);
+ setError(null);
+ setIsLoadingNext(false);
+ setIsLoadingPrevious(false);
+
+ try {
+ // Calculate the correct limit based on whether custom limit is being used
+ let limit: number | undefined;
+ if (paginationSettings.isCustomLimit) {
+ const customLimitNum = parseInt(paginationSettings.customLimit);
+ limit = customLimitNum;
+ } else {
+ limit = paginationSettings.pageLimit === 0 ? undefined : paginationSettings.pageLimit;
+ }
+
+ // Build custom query with all provided parameters
+ const querySpec: Array<{ name: string; value: string }> = [];
+
+ // Add digitalTwinType based on part type selection
+ querySpec.push({
+ name: 'digitalTwinType',
+ value: partType === 'Catalog' ? 'PartType' : 'PartInstance'
+ });
+
+ // Add all provided search parameters
+ if (filters.customerPartId.trim()) {
+ querySpec.push({
+ name: 'customerPartId',
+ value: filters.customerPartId.trim()
+ });
+ }
+
+ if (filters.manufacturerPartId.trim()) {
+ querySpec.push({
+ name: 'manufacturerPartId',
+ value: filters.manufacturerPartId.trim()
+ });
+ }
+
+ if (filters.globalAssetId.trim()) {
+ querySpec.push({
+ name: 'globalAssetId',
+ value: filters.globalAssetId.trim()
+ });
+ }
+
+ // Only add partInstanceId if part type is Serialized (PartInstance)
+ if (partType === 'Serialized' && filters.partInstanceId.trim()) {
+ querySpec.push({
+ name: 'partInstanceId',
+ value: filters.partInstanceId.trim()
+ });
+ }
+
+ // Use custom query for comprehensive search
+ const response = await discoverShellsWithCustomQuery(bpnl, querySpec, limit);
+
+ setCurrentResponse(response);
+
+ // Check if the API returned an error in the response
+ if (response.error) {
+ if (response.error.toLowerCase().includes('no dtrs found')) {
+ setError(`No Digital Twin Registries found for partner "${bpnl}". Please verify the BPNL is correct and if the partner has a Connector (with a reachable DTR) connected in the same dataspace as you.`);
+ } else {
+ setError(`Search failed: ${response.error}`);
+ }
+ return;
+ }
+
+ // Check if no shell descriptors were found
+ if (!response.shellDescriptors || response.shellDescriptors.length === 0) {
+ setError('No digital twins found for the specified criteria. Please try different search parameters.');
+ return;
+ }
+
+ // Create paginator
+ const digitalTwinType = partType === 'Catalog' ? 'PartType' : 'PartInstance';
+ const newPaginator = new ShellDiscoveryPaginator(
+ response,
+ bpnl,
+ digitalTwinType,
+ limit
+ );
+ setPaginator(newPaginator);
+
+ // Create DTR mapping if DTRs are available
+ const shellToDtrMap = response.dtrs ? createShellToDtrMap(response.dtrs as unknown as Array & { shells?: string[] }>) : undefined;
+
+ // Process results based on part type
+ if (partType === 'Catalog') {
+ const cards = convertToPartCards(response.shellDescriptors, shellToDtrMap);
+ setPartTypeCards(cards);
+ setSerializedParts([]);
+ } else {
+ const serialized = convertToSerializedParts(response.shellDescriptors, shellToDtrMap);
+ setSerializedParts(serialized);
+ setPartTypeCards([]);
+ }
+
+ setCurrentPage(response.pagination?.page || 1);
+ // Calculate total pages
+ if (limit === undefined) {
+ setTotalPages(1); // No pagination when no limit is set
+ } else {
+ setTotalPages(Math.ceil(response.shellsFound / limit));
+ }
+
+ // Mark that search has been performed successfully
+ setHasSearched(true);
+
+ } catch (err) {
+ console.error('Search error:', err);
+
+ let errorMessage = 'Error searching for parts. Please try again.';
+
+ if (err instanceof Error) {
+ errorMessage = `Search failed: ${err.message}`;
+ } else if (typeof err === 'string') {
+ errorMessage = `Search failed: ${err}`;
+ } else if (err && typeof err === 'object') {
+ if ('response' in err && err.response) {
+ const axiosErr = err as { response: { data?: { error?: string; message?: string }; status: number; statusText: string } };
+ if (axiosErr.response.data?.error) {
+ errorMessage = `API Error: ${axiosErr.response.data.error}`;
+ } else if (axiosErr.response.data?.message) {
+ errorMessage = `API Error: ${axiosErr.response.data.message}`;
+ } else if (axiosErr.response.statusText) {
+ errorMessage = `HTTP ${axiosErr.response.status}: ${axiosErr.response.statusText}`;
+ } else {
+ errorMessage = `HTTP Error ${axiosErr.response.status}`;
+ }
+ } else if ('message' in err) {
+ const errWithMessage = err as { message: string };
+ errorMessage = `Search failed: ${errWithMessage.message}`;
+ }
+ }
+
+ setError(errorMessage);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const performSingleTwinSearch = async (bpnl: string, aasId: string) => {
+ setIsLoading(true);
+ setError(null);
+ setSingleTwinResult(null);
+
+ try {
+ const response = await discoverSingleShell(bpnl, aasId.trim());
+ setSingleTwinResult(response);
+ setHasSearched(true);
+ } catch (err) {
+ let errorMessage = 'Failed to discover digital twin';
+
+ if (err instanceof Error) {
+ errorMessage = `Single twin search failed: ${err.message}`;
+ } else if (typeof err === 'string') {
+ errorMessage = `Single twin search failed: ${err}`;
+ } else if (err && typeof err === 'object') {
+ if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response) {
+ const responseData = err.response.data as Record;
+ if (typeof responseData.message === 'string') {
+ errorMessage = `Single twin search failed: ${responseData.message}`;
+ }
+ } else if ('message' in err) {
+ const errWithMessage = err as { message: string };
+ errorMessage = `Single twin search failed: ${errWithMessage.message}`;
+ }
+ }
+
+ setError(errorMessage);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handlePageChange = async (page: number, partType: PartType, bpnl: string) => {
+ if (!paginator || page === currentPage) return;
+
+ // Determine direction and set appropriate loading state
+ const isNext = page === currentPage + 1;
+ const isPrevious = page === currentPage - 1;
+
+ if (isNext) {
+ setIsLoadingNext(true);
+ } else if (isPrevious) {
+ setIsLoadingPrevious(true);
+ }
+
+ setError(null);
+
+ try {
+ let newResponse: ShellDiscoveryResponse | null = null;
+
+ // Handle sequential navigation
+ if (page === currentPage + 1 && paginator.hasNext()) {
+ newResponse = await paginator.next();
+ } else if (page === currentPage - 1 && paginator.hasPrevious()) {
+ newResponse = await paginator.previous();
+ } else {
+ setError(`Direct navigation to page ${page} is not supported. Please use next/previous navigation.`);
+ return;
+ }
+
+ if (newResponse) {
+ // Check if the pagination response contains an error
+ if (newResponse.error) {
+ if (newResponse.error.toLowerCase().includes('no dtrs found')) {
+ setError(`No Digital Twin Registries found for partner "${bpnl}" on page ${page}. Please verify the BPNL is correct and the partner has registered digital twins.`);
+ } else {
+ setError(`Pagination failed: ${newResponse.error}`);
+ }
+ return;
+ }
+
+ setCurrentResponse(newResponse);
+ setCurrentPage(newResponse.pagination?.page || currentPage);
+
+ // Create DTR mapping if DTRs are available
+ const shellToDtrMap = newResponse.dtrs ? createShellToDtrMap(newResponse.dtrs as unknown as Array & { shells?: string[] }>) : undefined;
+
+ // Update results based on part type
+ if (partType === 'Catalog') {
+ const cards = convertToPartCards(newResponse.shellDescriptors, shellToDtrMap);
+ setPartTypeCards(cards);
+ } else {
+ const serialized = convertToSerializedParts(newResponse.shellDescriptors, shellToDtrMap);
+ setSerializedParts(serialized);
+ }
+ } else {
+ setError('No more pages available in that direction.');
+ }
+ } catch (err) {
+ console.error('Pagination error:', err);
+
+ let errorMessage = 'Error loading page. Please try again.';
+
+ if (err instanceof Error) {
+ errorMessage = `Pagination failed: ${err.message}`;
+ } else if (typeof err === 'string') {
+ errorMessage = `Pagination failed: ${err}`;
+ } else if (err && typeof err === 'object') {
+ if ('response' in err && err.response) {
+ const axiosErr = err as { response: { data?: { error?: string; message?: string }; status: number; statusText: string } };
+ if (axiosErr.response.data?.error) {
+ errorMessage = `Pagination API Error: ${axiosErr.response.data.error}`;
+ } else if (axiosErr.response.data?.message) {
+ errorMessage = `Pagination API Error: ${axiosErr.response.data.message}`;
+ } else {
+ errorMessage = `Pagination HTTP ${axiosErr.response.status}: ${axiosErr.response.statusText}`;
+ }
+ } else if ('message' in err) {
+ const errWithMessage = err as { message: string };
+ errorMessage = `Pagination failed: ${errWithMessage.message}`;
+ }
+ }
+
+ setError(errorMessage);
+ } finally {
+ // Clean up pagination loading states
+ setIsLoadingNext(false);
+ setIsLoadingPrevious(false);
+ }
+ };
+
+ return {
+ // State
+ partTypeCards,
+ serializedParts,
+ currentResponse,
+ paginator,
+ currentPage,
+ totalPages,
+ singleTwinResult,
+ isLoading,
+ error,
+ hasSearched,
+ isLoadingNext,
+ isLoadingPrevious,
+
+ // Actions
+ resetSearch,
+ clearError,
+ performDiscoverySearch,
+ performSingleTwinSearch,
+ handlePageChange,
+ };
+};
diff --git a/ichub-frontend/src/features/part-discovery/index.ts b/ichub-frontend/src/features/part-discovery/index.ts
new file mode 100644
index 00000000..94d14a4e
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/index.ts
@@ -0,0 +1,44 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+// Export components
+export { PaginationControls } from './components/PaginationControls';
+export { SearchModeToggle } from './components/SearchModeToggle';
+export { FilterChips } from './components/FilterChips';
+export { PartnerSearch } from './components/PartnerSearch';
+export { SingleTwinSearch } from './components/SingleTwinSearch';
+export { SearchHeader } from './components/SearchHeader';
+export { default as PartsDiscoverySidebar } from './components/PartsDiscoverySidebar';
+
+// Export hooks
+export { usePartsDiscoverySearch } from './hooks/usePartsDiscoverySearch';
+
+// Export types
+export * from './types';
+
+// Export utilities
+export * from './utils';
+export * from './dtr-utils';
+export * from './data-converters';
+
+// Export API
+export * from './api';
diff --git a/ichub-frontend/src/features/part-discovery/types.ts b/ichub-frontend/src/features/part-discovery/types.ts
new file mode 100644
index 00000000..29aee4c7
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/types.ts
@@ -0,0 +1,68 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import { AASData } from './utils';
+
+export interface PartCardData {
+ id: string;
+ manufacturerId: string;
+ manufacturerPartId: string;
+ customerPartId?: string;
+ name?: string;
+ category?: string;
+ digitalTwinType: string;
+ globalAssetId: string;
+ submodelCount: number;
+ dtrIndex?: number;
+ rawTwinData?: AASData;
+}
+
+export interface SerializedPartData {
+ id: string;
+ globalAssetId: string;
+ aasId: string;
+ idShort?: string;
+ manufacturerId: string;
+ manufacturerPartId: string;
+ customerPartId?: string;
+ partInstanceId?: string;
+ digitalTwinType: string;
+ submodelCount: number;
+ dtrIndex?: number;
+ rawTwinData?: AASData;
+}
+
+export interface SearchFilters {
+ customerPartId: string;
+ manufacturerPartId: string;
+ globalAssetId: string;
+ partInstanceId: string;
+}
+
+export interface PaginationSettings {
+ pageLimit: number;
+ customLimit: string;
+ isCustomLimit: boolean;
+}
+
+export type SearchMode = 'discovery' | 'single';
+export type PartType = 'Catalog' | 'Serialized';
diff --git a/ichub-frontend/src/features/part-discovery/utils.ts b/ichub-frontend/src/features/part-discovery/utils.ts
new file mode 100644
index 00000000..c23f2165
--- /dev/null
+++ b/ichub-frontend/src/features/part-discovery/utils.ts
@@ -0,0 +1,473 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ ********************************************************************************/
+
+import { StatusVariants } from '../../types/statusVariants';
+import { ApiPartData, PartType } from '../../types/product';
+import { SharedPartner } from '../../types/sharedPartners';
+
+// Helper function to map numeric API status to StatusVariants
+export const mapApiStatusToVariant = (apiStatus: number): StatusVariants => {
+ switch (apiStatus) {
+ case 0:
+ return StatusVariants.draft;
+ case 1:
+ return StatusVariants.pending;
+ case 2:
+ return StatusVariants.registered;
+ case 3:
+ return StatusVariants.shared;
+ default:
+ return StatusVariants.draft;
+ }
+};
+
+// Helper function to map StatusVariants to numeric API status
+export const mapVariantToApiStatus = (variant: StatusVariants): number => {
+ switch (variant) {
+ case StatusVariants.draft:
+ return 0;
+ case StatusVariants.pending:
+ return 1;
+ case StatusVariants.registered:
+ return 2;
+ case StatusVariants.shared:
+ return 3;
+ default:
+ return 0; // Default to draft if unknown variant
+ }
+};
+
+// Maps ApiPartData to PartInstance
+export const mapApiPartDataToPartType = (apiData: ApiPartData): PartType => {
+ const { status, ...rest } = apiData;
+ return {
+ ...rest,
+ status: mapApiStatusToVariant(status),
+ };
+};
+
+// Maps PartInstance to ApiPartData
+export const mapPartInstanceToApiPartData = (partInstance: PartType): ApiPartData => {
+ const { status, ...rest } = partInstance;
+ return {
+ ...rest,
+ status: mapVariantToApiStatus(status ?? StatusVariants.draft),
+ };
+};
+
+export const mapSharePartCustomerPartIds = (
+ customerPartIds: Record
+): SharedPartner[] => {
+ return Object.entries(customerPartIds).map(([customerPartId, { name, bpnl }]) => ({
+ name,
+ bpnl,
+ customerPartId,
+ }));
+};
+
+// Types for AAS data structure
+export interface AASData {
+ description: string[];
+ displayName: string[];
+ globalAssetId: string;
+ id: string;
+ idShort?: string; // Optional idShort field
+ specificAssetIds: SpecificAssetId[];
+ submodelDescriptors: SubmodelDescriptor[];
+}
+
+export interface SpecificAssetId {
+ supplementalSemanticIds: string[];
+ name: string;
+ value: string;
+ externalSubjectId: ExternalReference;
+}
+
+export interface ExternalReference {
+ type: string;
+ keys: ReferenceKey[];
+}
+
+export interface ReferenceKey {
+ type: string;
+ value: string;
+}
+
+export interface SubmodelDescriptor {
+ endpoints: Endpoint[];
+ idShort: string;
+ id: string;
+ semanticId: ExternalReference;
+ supplementalSemanticId: string[];
+ description: string[];
+ displayName: string[];
+}
+
+export interface Endpoint {
+ interface: string;
+ protocolInformation: ProtocolInformation;
+}
+
+export interface ProtocolInformation {
+ href: string;
+ endpointProtocol: string;
+ endpointProtocolVersion: string[];
+ subprotocol: string;
+ subprotocolBody: string;
+ subprotocolBodyEncoding: string;
+ securityAttributes: SecurityAttribute[];
+}
+
+export interface SecurityAttribute {
+ type: string;
+ key: string;
+ value: string;
+}
+
+// Utility functions for parsing AAS data
+
+/**
+ * Extracts a specific asset ID value by name from the AAS data
+ */
+export const getSpecificAssetIdByName = (aasData: AASData, name: string): string | null => {
+ const specificAssetId = aasData.specificAssetIds.find(id => id.name === name);
+ return specificAssetId?.value || null;
+};
+
+/**
+ * Gets the manufacturer part ID from the AAS data
+ */
+export const getManufacturerPartId = (aasData: AASData): string | null => {
+ return getSpecificAssetIdByName(aasData, 'manufacturerPartId');
+};
+
+/**
+ * Gets the manufacturer ID from the AAS data
+ */
+export const getManufacturerId = (aasData: AASData): string | null => {
+ return getSpecificAssetIdByName(aasData, 'manufacturerId');
+};
+
+/**
+ * Gets the customer part ID from the AAS data
+ */
+export const getCustomerPartId = (aasData: AASData): string | null => {
+ return getSpecificAssetIdByName(aasData, 'customerPartId');
+};
+
+/**
+ * Gets the digital twin type from the AAS data
+ */
+export const getDigitalTwinType = (aasData: AASData): string | null => {
+ return getSpecificAssetIdByName(aasData, 'digitalTwinType');
+};
+
+/**
+ * Gets the part instance ID from the AAS data
+ */
+export const getPartInstanceId = (aasData: AASData): string | null => {
+ return getSpecificAssetIdByName(aasData, 'partInstanceId');
+};
+
+/**
+ * Gets all specific asset IDs as a key-value map
+ */
+export const getAllSpecificAssetIds = (aasData: AASData): Record => {
+ return aasData.specificAssetIds.reduce((acc, asset) => {
+ acc[asset.name] = asset.value;
+ return acc;
+ }, {} as Record);
+};
+
+/**
+ * Extracts submodel descriptor by idShort
+ */
+export const getSubmodelDescriptorByIdShort = (aasData: AASData, idShort: string): SubmodelDescriptor | null => {
+ return aasData.submodelDescriptors.find(descriptor => descriptor.idShort === idShort) || null;
+};
+
+/**
+ * Extracts submodel descriptor by semantic ID value
+ */
+export const getSubmodelDescriptorBySemanticId = (aasData: AASData, semanticIdValue: string): SubmodelDescriptor | null => {
+ return aasData.submodelDescriptors.find(descriptor =>
+ descriptor.semanticId?.keys?.some(key => key.value === semanticIdValue)
+ ) || null;
+};
+
+/**
+ * Gets all submodel endpoints for a specific submodel
+ */
+export const getSubmodelEndpoints = (submodelDescriptor: SubmodelDescriptor): string[] => {
+ return submodelDescriptor.endpoints.map(endpoint => endpoint.protocolInformation.href);
+};
+
+/**
+ * Extracts the DSP endpoint from subprotocolBody
+ */
+export const getDspEndpointFromSubmodel = (submodelDescriptor: SubmodelDescriptor): string | null => {
+ const endpoint = submodelDescriptor.endpoints[0];
+ if (!endpoint?.protocolInformation?.subprotocolBody) return null;
+
+ const match = endpoint.protocolInformation.subprotocolBody.match(/dspEndpoint=([^;]+)/);
+ return match ? match[1] : null;
+};
+
+/**
+ * Extracts the asset ID from subprotocolBody
+ */
+export const getAssetIdFromSubmodel = (submodelDescriptor: SubmodelDescriptor): string | null => {
+ const endpoint = submodelDescriptor.endpoints[0];
+ if (!endpoint?.protocolInformation?.subprotocolBody) return null;
+
+ const match = endpoint.protocolInformation.subprotocolBody.match(/id=([^;]+)/);
+ return match ? match[1] : null;
+};
+
+/**
+ * Gets the external subject ID (BPNL) from a specific asset ID
+ */
+export const getExternalSubjectId = (specificAssetId: SpecificAssetId): string | null => {
+ return specificAssetId.externalSubjectId?.keys?.[0]?.value || null;
+};
+
+/**
+ * Checks if the AAS data represents a specific digital twin type
+ */
+export const isDigitalTwinType = (aasData: AASData, expectedType: string): boolean => {
+ const digitalTwinType = getDigitalTwinType(aasData);
+ return digitalTwinType === expectedType;
+};
+
+/**
+ * Validates if the AAS data structure is complete and valid
+ */
+export const validateAASData = (aasData: AASData): { isValid: boolean; errors: string[] } => {
+ const errors: string[] = [];
+
+ if (!aasData.globalAssetId) {
+ errors.push('Missing globalAssetId');
+ }
+
+ if (!aasData.id) {
+ errors.push('Missing id');
+ }
+
+ if (!aasData.specificAssetIds || aasData.specificAssetIds.length === 0) {
+ errors.push('Missing or empty specificAssetIds');
+ }
+
+ if (!aasData.submodelDescriptors || aasData.submodelDescriptors.length === 0) {
+ errors.push('Missing or empty submodelDescriptors');
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors
+ };
+};
+
+/**
+ * Extracts summary information from AAS data for display purposes
+ */
+export const getAASDataSummary = (aasData: AASData) => {
+ const manufacturerPartId = getManufacturerPartId(aasData);
+ const manufacturerId = getManufacturerId(aasData);
+ const customerPartId = getCustomerPartId(aasData);
+ const digitalTwinType = getDigitalTwinType(aasData);
+ const partInstanceId = getPartInstanceId(aasData);
+
+ return {
+ globalAssetId: aasData.globalAssetId,
+ id: aasData.id,
+ manufacturerPartId,
+ manufacturerId,
+ customerPartId,
+ digitalTwinType,
+ partInstanceId,
+ submodelCount: aasData.submodelDescriptors.length
+ };
+};
+
+// Shell Discovery Response Types and Utilities
+export interface ShellDiscoveryResponse {
+ shellDescriptors: AASData[];
+ dtrs: DTRInfo[];
+ shellsFound: number;
+ pagination: PaginationInfo;
+ error?: string; // Optional error field for API error responses
+}
+
+export interface DTRInfo {
+ connectorUrl: string;
+ assetId: string;
+ status: string;
+ shellsFound: number;
+ shells: string[];
+ paging_metadata: {
+ cursor?: string;
+ };
+}
+
+export interface PaginationInfo {
+ page: number;
+ next?: string;
+ previous?: string;
+}
+
+/**
+ * Check if there are more pages available (next page exists)
+ */
+export const hasNextPage = (response: ShellDiscoveryResponse): boolean => {
+ return !!response.pagination.next;
+};
+
+/**
+ * Check if there are previous pages available
+ */
+export const hasPreviousPage = (response: ShellDiscoveryResponse): boolean => {
+ return !!response.pagination.previous;
+};
+
+/**
+ * Get the next page cursor from the response
+ */
+export const getNextPageCursor = (response: ShellDiscoveryResponse): string | null => {
+ return response.pagination.next || null;
+};
+
+/**
+ * Get the previous page cursor from the response
+ */
+export const getPreviousPageCursor = (response: ShellDiscoveryResponse): string | null => {
+ return response.pagination.previous || null;
+};
+
+/**
+ * Extract summary information from shell discovery response for display
+ */
+export const getShellDiscoverySummary = (response: ShellDiscoveryResponse) => {
+ const shellSummaries = response.shellDescriptors.map(shell => getAASDataSummary(shell));
+
+ return {
+ totalShellsFound: response.shellsFound,
+ currentPageShells: response.shellDescriptors.length,
+ currentPage: response.pagination.page,
+ hasNext: hasNextPage(response),
+ hasPrevious: hasPreviousPage(response),
+ dtrsCount: response.dtrs.length,
+ successfulDtrs: response.dtrs.filter(dtr => dtr.status === 'success').length,
+ shells: shellSummaries
+ };
+};
+
+/**
+ * Filter shells by manufacturer ID
+ */
+export const filterShellsByManufacturerId = (
+ response: ShellDiscoveryResponse,
+ manufacturerId: string
+): AASData[] => {
+ return response.shellDescriptors.filter(shell =>
+ getManufacturerId(shell) === manufacturerId
+ );
+};
+
+/**
+ * Filter shells by customer part ID
+ */
+export const filterShellsByCustomerPartId = (
+ response: ShellDiscoveryResponse,
+ customerPartId: string
+): AASData[] => {
+ return response.shellDescriptors.filter(shell =>
+ getCustomerPartId(shell) === customerPartId
+ );
+};
+
+/**
+ * Group shells by manufacturer ID
+ */
+export const groupShellsByManufacturerId = (
+ response: ShellDiscoveryResponse
+): Record => {
+ return response.shellDescriptors.reduce((acc, shell) => {
+ const manufacturerId = getManufacturerId(shell);
+ if (manufacturerId) {
+ if (!acc[manufacturerId]) {
+ acc[manufacturerId] = [];
+ }
+ acc[manufacturerId].push(shell);
+ }
+ return acc;
+ }, {} as Record);
+};
+
+/**
+ * Get all unique manufacturer part IDs from the response
+ */
+export const getUniqueManufacturerPartIds = (response: ShellDiscoveryResponse): string[] => {
+ const partIds = response.shellDescriptors
+ .map(shell => getManufacturerPartId(shell))
+ .filter((id): id is string => id !== null);
+
+ return [...new Set(partIds)];
+};
+
+/**
+ * Get all unique customer part IDs from the response
+ */
+export const getUniqueCustomerPartIds = (response: ShellDiscoveryResponse): string[] => {
+ const partIds = response.shellDescriptors
+ .map(shell => getCustomerPartId(shell))
+ .filter((id): id is string => id !== null);
+
+ return [...new Set(partIds)];
+};
+
+/**
+ * Find shells that have submodel descriptors
+ */
+export const getShellsWithSubmodels = (response: ShellDiscoveryResponse): AASData[] => {
+ return response.shellDescriptors.filter(shell =>
+ shell.submodelDescriptors && shell.submodelDescriptors.length > 0
+ );
+};
+
+/**
+ * Convert shell discovery response to a format suitable for table display
+ */
+export const formatShellsForTable = (response: ShellDiscoveryResponse) => {
+ return response.shellDescriptors.map(shell => {
+ const summary = getAASDataSummary(shell);
+ return {
+ id: shell.id,
+ globalAssetId: shell.globalAssetId,
+ manufacturerPartId: summary.manufacturerPartId || 'N/A',
+ manufacturerId: summary.manufacturerId || 'N/A',
+ customerPartId: summary.customerPartId || 'N/A',
+ digitalTwinType: summary.digitalTwinType || 'N/A',
+ hasSubmodels: shell.submodelDescriptors.length > 0,
+ submodelCount: shell.submodelDescriptors.length
+ };
+ });
+};
\ No newline at end of file
diff --git a/ichub-frontend/src/hooks/useAdditionalSidebar.ts b/ichub-frontend/src/hooks/useAdditionalSidebar.ts
new file mode 100644
index 00000000..bd93be44
--- /dev/null
+++ b/ichub-frontend/src/hooks/useAdditionalSidebar.ts
@@ -0,0 +1,34 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import { useContext } from 'react';
+import { AdditionalSidebarContext } from '../contexts/AdditionalSidebarContext';
+
+export const useAdditionalSidebar = () => {
+ const context = useContext(AdditionalSidebarContext);
+ if (!context) {
+ throw new Error('useAdditionalSidebar must be used within an AdditionalSidebarProvider');
+ }
+ return context;
+};
+
+export default useAdditionalSidebar;
diff --git a/ichub-frontend/src/layouts/MainLayout.tsx b/ichub-frontend/src/layouts/MainLayout.tsx
index a59ab62d..440c65e6 100644
--- a/ichub-frontend/src/layouts/MainLayout.tsx
+++ b/ichub-frontend/src/layouts/MainLayout.tsx
@@ -24,9 +24,12 @@ import { Outlet } from "react-router-dom";
import Grid2 from '@mui/material/Grid2';
import Header from '../components/general/Header';
import Sidebar from '../components/general/Sidebar';
+import AdditionalSidebar from '../components/general/AdditionalSidebar';
+import { SidebarProvider } from '../contexts/SidebarContext';
+import { AdditionalSidebarProvider } from '../contexts/AdditionalSidebarContext';
import { features } from '../features/main';
-function MainLayout() {
+function MainLayoutContent() {
return (
@@ -36,12 +39,25 @@ function MainLayout() {
-
+
+
+
+
);
+}
+
+function MainLayout() {
+ return (
+
+
+
+
+
+ );
};
export default MainLayout;
\ No newline at end of file
diff --git a/ichub-frontend/src/pages/PartsDiscovery.tsx b/ichub-frontend/src/pages/PartsDiscovery.tsx
index 9c47ab6c..081e1a6e 100644
--- a/ichub-frontend/src/pages/PartsDiscovery.tsx
+++ b/ichub-frontend/src/pages/PartsDiscovery.tsx
@@ -20,186 +20,2103 @@
* SPDX-License-Identifier: Apache-2.0
********************************************************************************/
-import { useState } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
import {
Box,
Grid2,
Typography,
- Radio,
- RadioGroup,
- FormControlLabel,
- Slider,
TextField,
Button,
InputAdornment,
- IconButton
+ useTheme,
+ useMediaQuery,
+ IconButton,
+ Alert,
+ CircularProgress,
+ Card,
+ Chip,
+ Autocomplete
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
-import type { ApiPartData, PartType } from '../types/product';
-import { ProductCard } from '../features/catalog-management/components/product-list/ProductCard';
-import { mapApiPartDataToPartType } from '../features/catalog-management/utils';
-import InstanceProductsTable from '../features/catalog-management/components/product-detail/InstanceProductsTable';
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import ExpandLessIcon from '@mui/icons-material/ExpandLess';
+import VisibilityIcon from '@mui/icons-material/Visibility';
+import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
+import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
+import ChevronRightIcon from '@mui/icons-material/ChevronRight';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import CheckIcon from '@mui/icons-material/Check';
+import { CatalogPartsDiscovery } from '../features/part-discovery/components/catalog-parts/CatalogPartsDiscovery';
+import PartsDiscoverySidebar from '../features/part-discovery/components/PartsDiscoverySidebar';
+import SerializedPartsTable from '../features/part-discovery/components/SerializedPartsTable';
+import { SingleTwinResult } from '../features/part-discovery/components/SingleTwinResult';
+import { useAdditionalSidebar } from '../hooks/useAdditionalSidebar';
+import {
+ discoverShellsWithCustomQuery,
+ discoverSingleShell,
+ ShellDiscoveryPaginator,
+ SingleShellDiscoveryResponse
+} from '../features/part-discovery/api';
+import {
+ ShellDiscoveryResponse,
+ AASData,
+ getAASDataSummary
+} from '../features/part-discovery/utils';
+import { fetchPartners } from '../features/partner-management/api';
+import { PartnerInstance } from '../types/partner';
+
+interface PartCardData {
+ id: string;
+ manufacturerId: string;
+ manufacturerPartId: string;
+ customerPartId?: string;
+ name?: string;
+ category?: string;
+ digitalTwinType: string;
+ globalAssetId: string;
+ submodelCount: number;
+ dtrIndex?: number; // DTR index for display
+ idShort?: string; // Optional idShort from AAS data
+ rawTwinData?: AASData; // Raw AAS/shell data for download
+}
+
+interface SerializedPartData {
+ id: string;
+ globalAssetId: string;
+ aasId: string; // AAS Shell ID
+ manufacturerId: string;
+ manufacturerPartId: string;
+ customerPartId?: string;
+ partInstanceId?: string; // Part Instance ID
+ digitalTwinType: string;
+ submodelCount: number;
+ dtrIndex?: number; // DTR index for display
+ idShort?: string; // Optional idShort from AAS data
+ rawTwinData?: AASData; // Raw AAS/shell data for download
+}
+
+// Helper function to create a map from shell ID to DTR index
+const createShellToDtrMap = (dtrs: Array & { shells?: string[] }>): Map => {
+ const shellToDtrMap = new Map();
+ dtrs.forEach((dtr, dtrIndex) => {
+ if (dtr.shells && Array.isArray(dtr.shells)) {
+ dtr.shells.forEach((shellId: string) => {
+ shellToDtrMap.set(shellId, dtrIndex);
+ });
+ }
+ });
+ return shellToDtrMap;
+};
+
+// Helper function to get consistent colors for DTR identifiers
+const getDtrColor = (dtrIndex: number) => {
+ const baseColors = [
+ { bg: 'rgba(76, 175, 80, 0.9)', color: 'white', light: 'rgba(76, 175, 80, 0.1)', border: 'rgba(76, 175, 80, 0.3)' }, // Green
+ { bg: 'rgba(33, 150, 243, 0.9)', color: 'white', light: 'rgba(33, 150, 243, 0.1)', border: 'rgba(33, 150, 243, 0.3)' }, // Blue
+ { bg: 'rgba(255, 152, 0, 0.9)', color: 'white', light: 'rgba(255, 152, 0, 0.1)', border: 'rgba(255, 152, 0, 0.3)' }, // Orange
+ { bg: 'rgba(156, 39, 176, 0.9)', color: 'white', light: 'rgba(156, 39, 176, 0.1)', border: 'rgba(156, 39, 176, 0.3)' }, // Purple
+ { bg: 'rgba(244, 67, 54, 0.9)', color: 'white', light: 'rgba(244, 67, 54, 0.1)', border: 'rgba(244, 67, 54, 0.3)' }, // Red
+ { bg: 'rgba(0, 188, 212, 0.9)', color: 'white', light: 'rgba(0, 188, 212, 0.1)', border: 'rgba(0, 188, 212, 0.3)' }, // Cyan
+ { bg: 'rgba(139, 195, 74, 0.9)', color: 'white', light: 'rgba(139, 195, 74, 0.1)', border: 'rgba(139, 195, 74, 0.3)' }, // Light Green
+ { bg: 'rgba(121, 85, 72, 0.9)', color: 'white', light: 'rgba(121, 85, 72, 0.1)', border: 'rgba(121, 85, 72, 0.3)' }, // Brown
+ ];
+
+ const colorIndex = dtrIndex % baseColors.length;
+ const variation = Math.floor(dtrIndex / baseColors.length);
+
+ // For DTRs beyond 8, add opacity variations to distinguish them
+ const baseColor = baseColors[colorIndex];
+ const opacity = Math.max(0.7, 1 - (variation * 0.1)); // Gradually reduce opacity
+
+ return {
+ bg: baseColor.bg.replace('0.9)', `${opacity})`),
+ color: baseColor.color,
+ light: baseColor.light,
+ border: baseColor.border
+ };
+};
const PartsDiscovery = () => {
- const [partType, setPartType] = useState('Part');
- const [numParts, setNumParts] = useState(10);
+ const { showSidebar, hideSidebar, isVisible } = useAdditionalSidebar();
+
+ // Ref to prevent duplicate API calls in React StrictMode
+ const partnersLoadedRef = useRef(false);
+
+ const [partType, setPartType] = useState('Catalog');
const [bpnl, setBpnl] = useState('');
- const [parts, setParts] = useState([]);
+ const [selectedPartner, setSelectedPartner] = useState(null);
+ const [availablePartners, setAvailablePartners] = useState([]);
+ const [isLoadingPartners, setIsLoadingPartners] = useState(false);
+ const [globalAssetId, setGlobalAssetId] = useState('');
+ const [customerPartId, setCustomerPartId] = useState('');
+ const [manufacturerPartId, setManufacturerPartId] = useState('');
+ const [partInstanceId, setPartInstanceId] = useState('');
+ const [pageLimit, setPageLimit] = useState(10);
+ const [customLimit, setCustomLimit] = useState('');
+ const [isCustomLimit, setIsCustomLimit] = useState(false);
+
+ // Single Twin Search Mode
+ const [searchMode, setSearchMode] = useState<'discovery' | 'single' | 'view'>('discovery');
+ const [singleTwinAasId, setSingleTwinAasId] = useState('');
+ const [singleTwinResult, setSingleTwinResult] = useState(null);
+
+ // Twin View Mode (for viewing existing twin data)
+ const [viewingTwin, setViewingTwin] = useState(null);
+
+ // DTR Section Visibility
+ const [dtrSectionVisible, setDtrSectionVisible] = useState(false);
+
+ // DTR carousel state
+ const [dtrCarouselIndex, setDtrCarouselIndex] = useState(0);
+
+ // DTR copy states
+ const [copiedAssetId, setCopiedAssetId] = useState(null);
+ const [copiedConnectorUrl, setCopiedConnectorUrl] = useState(null);
+
+ // Results and pagination
+ const [partTypeCards, setPartTypeCards] = useState([]);
+ const [serializedParts, setSerializedParts] = useState([]);
+ const [currentResponse, setCurrentResponse] = useState(null);
+ const [paginator, setPaginator] = useState(null);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(0);
+
+ // Loading and error states
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [hasSearched, setHasSearched] = useState(false);
- const handleSearchClick = async () => {
- if (!bpnl) {
- alert('Please enter a partner BPNL');
+ // Pagination loading states
+ const [isLoadingNext, setIsLoadingNext] = useState(false);
+ const [isLoadingPrevious, setIsLoadingPrevious] = useState(false);
+
+ // Responsive design hooks
+ const theme = useTheme();
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'));
+
+ // DTR carousel configuration
+ const dtrItemsPerSlide = isMobile ? 1 : 2;
+ const isSingleDtr = currentResponse?.dtrs.length === 1;
+
+ // DTR carousel navigation functions
+ const handleDtrPrevious = () => {
+ setDtrCarouselIndex(prev => Math.max(0, prev - dtrItemsPerSlide));
+ };
+
+ const handleDtrNext = () => {
+ if (currentResponse?.dtrs) {
+ const maxIndex = Math.max(0, currentResponse.dtrs.length - dtrItemsPerSlide);
+ setDtrCarouselIndex(prev => Math.min(maxIndex, prev + dtrItemsPerSlide));
+ }
+ };
+
+ // Reset DTR carousel when DTRs change
+ useEffect(() => {
+ setDtrCarouselIndex(0);
+ }, [currentResponse?.dtrs]);
+
+ // Copy functions for DTR data
+ const handleCopyAssetId = async (assetId: string, dtrIndex: number) => {
+ try {
+ await navigator.clipboard.writeText(assetId);
+ setCopiedAssetId(`${dtrIndex}-${assetId}`);
+ setTimeout(() => setCopiedAssetId(null), 2000);
+ } catch (err) {
+ console.error('Failed to copy asset ID:', err);
+ }
+ };
+
+ const handleCopyConnectorUrl = async (connectorUrl: string, dtrIndex: number) => {
+ try {
+ await navigator.clipboard.writeText(connectorUrl);
+ setCopiedConnectorUrl(`${dtrIndex}-${connectorUrl}`);
+ setTimeout(() => setCopiedConnectorUrl(null), 2000);
+ } catch (err) {
+ console.error('Failed to copy connector URL:', err);
+ }
+ };
+
+ // Show sidebar when in discovery mode and not searched
+ useEffect(() => {
+ if (searchMode === 'discovery' && !hasSearched) {
+ showSidebar(
+
+ );
+ } else {
+ hideSidebar();
+ }
+ }, [searchMode, hasSearched, partType, pageLimit, customLimit, isCustomLimit, customerPartId, manufacturerPartId, globalAssetId, partInstanceId, showSidebar, hideSidebar]);
+
+ // Cleanup: Hide sidebar when component unmounts (navigation away from PartsDiscovery)
+ useEffect(() => {
+ return () => {
+ hideSidebar();
+ };
+ }, [hideSidebar]);
+
+ // Load available partners on component mount
+ useEffect(() => {
+ const loadPartners = async () => {
+ // Prevent duplicate calls in React StrictMode
+ if (partnersLoadedRef.current) {
+ return;
+ }
+ partnersLoadedRef.current = true;
+
+ try {
+ setIsLoadingPartners(true);
+ const partners = await fetchPartners();
+ setAvailablePartners(partners);
+ } catch (err) {
+ console.error('Error loading partners:', err);
+ // Don't show error for partners loading as it's not critical
+ // Reset the ref on error so it can be retried
+ partnersLoadedRef.current = false;
+ } finally {
+ setIsLoadingPartners(false);
+ }
+ };
+
+ loadPartners();
+ }, []);
+
+ // Helper function to get company name from BPNL
+ const getCompanyName = (bpnlValue: string): string => {
+ const partner = availablePartners.find(p => p.bpnl === bpnlValue);
+ return partner?.name || bpnlValue;
+ };
+
+ // Handle part type change and clear Part Instance ID when switching to Part
+ const handlePartTypeChange = (event: React.ChangeEvent) => {
+ const newPartType = event.target.value;
+ setPartType(newPartType);
+
+ // Clear Part Instance ID when switching to Part Type
+ if (newPartType === 'Catalog') {
+ setPartInstanceId('');
+ }
+ };
+
+ // Helper function to display filters sidebar
+ const handleDisplayFilters = () => {
+ showSidebar(
+
+ );
+ };
+
+ // Handle single twin search
+ const handleSingleTwinSearch = async () => {
+ if (!bpnl.trim()) {
+ setError('Please enter a partner BPNL');
+ return;
+ }
+
+ if (!singleTwinAasId.trim()) {
+ setError('Please enter an AAS ID');
return;
}
+ setIsLoading(true);
+ setError(null);
+ setSingleTwinResult(null);
+
try {
- console.log(`Searching for parts with BPNL: ${bpnl}`);
- // Here we should call the API to fetch parts based on the BPNL and part type
- const fakeParts: ApiPartData[] = Array.from({ length: numParts }).map((_, idx) => ({
- manufacturerId: `MFR-${idx + 1}`,
- manufacturerPartId: `PART-${idx + 1}`,
- name: `Part Name ${idx + 1}`,
- status: 2,
- description: `Description for part ${idx + 1}`,
- category: 'Category A',
- materials: [],
- }));
-
- const mappedFakeParts: PartType[] = fakeParts.map((part) =>
- mapApiPartDataToPartType(part)
+ const response = await discoverSingleShell(bpnl, singleTwinAasId.trim());
+ setSingleTwinResult(response);
+ setHasSearched(true);
+ } catch (err) {
+ let errorMessage = 'Failed to discover digital twin';
+
+ if (err instanceof Error) {
+ errorMessage = `Single twin search failed: ${err.message}`;
+ } else if (typeof err === 'string') {
+ errorMessage = `Single twin search failed: ${err}`;
+ } else if (err && typeof err === 'object') {
+ if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response) {
+ const responseData = err.response.data as Record;
+ if (typeof responseData.message === 'string') {
+ errorMessage = `Single twin search failed: ${responseData.message}`;
+ }
+ } else if ('message' in err) {
+ const errWithMessage = err as { message: string };
+ errorMessage = `Single twin search failed: ${errWithMessage.message}`;
+ }
+ }
+
+ setError(errorMessage);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Helper function to generate active filter chips - scalable for future filters
+ const getActiveFilterChips = () => {
+ const filters = [
+ {
+ value: customerPartId,
+ label: 'Customer Part ID',
+ tooltip: 'Customer Part ID'
+ },
+ {
+ value: manufacturerPartId,
+ label: 'Manufacturer Part ID',
+ tooltip: 'Manufacturer Part ID'
+ },
+ {
+ value: globalAssetId,
+ label: 'Global Asset ID',
+ tooltip: 'Global Asset ID'
+ },
+ // Only show Part Instance ID filter when Part Instance is selected
+ ...(partType === 'Serialized' ? [{
+ value: partInstanceId,
+ label: 'Part Instance ID',
+ tooltip: 'Part Instance Identifier'
+ }] : [])
+ // Future filters can be easily added here:
+ // {
+ // value: someNewFilter,
+ // label: 'New Filter Name',
+ // tooltip: 'New Filter Description'
+ // }
+ ];
+
+ return filters
+ .filter(filter => filter.value && filter.value.trim())
+ .map((filter, index) => {
+ return (
+
+
+ {filter.label}:
+
+
+ {filter.value}
+
+
+ }
+ size="medium"
+ color="primary"
+ variant="filled"
+ title={`${filter.tooltip}: ${filter.value}`}
+ sx={{
+ backgroundColor: 'rgba(25, 118, 210, 0.1)',
+ color: '#1976d2',
+ border: '1px solid rgba(25, 118, 210, 0.3)',
+ borderRadius: '20px',
+ fontSize: '0.85rem',
+ fontWeight: '500',
+ px: 2,
+ py: 0.5,
+ height: 'auto',
+ minHeight: '32px',
+ maxWidth: '100%',
+ '& .MuiChip-label': {
+ px: 1,
+ py: 0.5,
+ whiteSpace: 'nowrap',
+ overflow: 'visible',
+ textOverflow: 'unset'
+ },
+ '&:hover': {
+ backgroundColor: 'rgba(25, 118, 210, 0.15)',
+ borderColor: 'rgba(25, 118, 210, 0.5)',
+ transform: 'translateY(-1px)',
+ boxShadow: '0 4px 12px rgba(25, 118, 210, 0.2)'
+ },
+ transition: 'all 0.2s ease-in-out'
+ }}
+ />
+ );
+ });
+ };
+
+ // Convert AAS data to card format
+ const convertToPartCards = (shells: AASData[], shellToDtrMap?: Map): PartCardData[] => {
+ return shells.map(shell => {
+ const summary = getAASDataSummary(shell);
+ const dtrIndex = shellToDtrMap?.get(shell.id);
+ return {
+ id: shell.id,
+ manufacturerId: summary.manufacturerId || 'Unknown',
+ manufacturerPartId: summary.manufacturerPartId || 'Unknown',
+ customerPartId: summary.customerPartId || undefined,
+ name: `${summary.manufacturerPartId}`,
+ category: summary.customerPartId || undefined,
+ digitalTwinType: summary.digitalTwinType || 'Unknown',
+ globalAssetId: shell.globalAssetId,
+ submodelCount: summary.submodelCount,
+ dtrIndex,
+ idShort: shell.idShort, // Include idShort from AAS data
+ rawTwinData: shell
+ };
+ });
+ };
+
+ // Convert AAS data to serialized parts format
+ const convertToSerializedParts = (shells: AASData[], shellToDtrMap?: Map): SerializedPartData[] => {
+ return shells.map(shell => {
+ const summary = getAASDataSummary(shell);
+ const dtrIndex = shellToDtrMap?.get(shell.id);
+ return {
+ id: shell.id,
+ globalAssetId: shell.globalAssetId,
+ aasId: shell.id, // AAS Shell ID
+ manufacturerId: summary.manufacturerId || 'Unknown',
+ manufacturerPartId: summary.manufacturerPartId || 'Unknown',
+ customerPartId: summary.customerPartId || undefined,
+ partInstanceId: summary.partInstanceId || undefined,
+ digitalTwinType: summary.digitalTwinType || 'Unknown',
+ submodelCount: summary.submodelCount,
+ dtrIndex,
+ idShort: shell.idShort, // Include idShort from AAS data
+ rawTwinData: shell
+ };
+ });
+ };
+
+ const handleGoBack = () => {
+ setHasSearched(false);
+ setCurrentResponse(null);
+ setPaginator(null);
+ setPartTypeCards([]);
+ setSerializedParts([]);
+ setCurrentPage(1);
+ setTotalPages(0);
+ setError(null);
+ // Reset pagination loading states
+ setIsLoadingNext(false);
+ setIsLoadingPrevious(false);
+ // Reset search fields
+ setBpnl('');
+ setSelectedPartner(null);
+ setCustomerPartId('');
+ setManufacturerPartId('');
+ };
+
+ const handleSearch = async () => {
+ if (!bpnl.trim()) {
+ setError('Please enter a partner BPNL');
+ return;
+ }
+
+ // Validate custom limit
+ if (isCustomLimit) {
+ if (!customLimit.trim()) {
+ setError('Please enter a custom limit or select a predefined option');
+ return;
+ }
+ const customLimitNum = parseInt(customLimit);
+ if (isNaN(customLimitNum) || customLimitNum < 1 || customLimitNum > 1000) {
+ setError('Custom limit must be a number between 1 and 1000');
+ return;
+ }
+ }
+
+ setIsLoading(true);
+ setError(null);
+ // Reset pagination loading states for new search
+ setIsLoadingNext(false);
+ setIsLoadingPrevious(false);
+
+ try {
+ // Calculate the correct limit based on whether custom limit is being used
+ let limit: number | undefined;
+ if (isCustomLimit) {
+ const customLimitNum = parseInt(customLimit);
+ limit = customLimitNum;
+ } else {
+ limit = pageLimit === 0 ? undefined : pageLimit; // No limit if pageLimit is 0
+ }
+
+ // Build custom query with all provided parameters
+ const querySpec: Array<{ name: string; value: string }> = [];
+
+ // Add digitalTwinType based on part type selection
+ querySpec.push({
+ name: 'digitalTwinType',
+ value: partType === 'Catalog' ? 'PartType' : 'PartInstance'
+ });
+
+ // Add all provided search parameters
+ if (customerPartId.trim()) {
+ querySpec.push({
+ name: 'customerPartId',
+ value: customerPartId.trim()
+ });
+ }
+
+ if (manufacturerPartId.trim()) {
+ querySpec.push({
+ name: 'manufacturerPartId',
+ value: manufacturerPartId.trim()
+ });
+ }
+
+ if (globalAssetId.trim()) {
+ querySpec.push({
+ name: 'globalAssetId',
+ value: globalAssetId.trim()
+ });
+ }
+
+ // Only add partInstanceId if part type is Serialized (PartInstance)
+ if (partType === 'Serialized' && partInstanceId.trim()) {
+ querySpec.push({
+ name: 'partInstanceId',
+ value: partInstanceId.trim()
+ });
+ }
+
+ // Use custom query for comprehensive search
+ const response = await discoverShellsWithCustomQuery(bpnl, querySpec, limit);
+
+ setCurrentResponse(response);
+
+ // Log the full response for debugging
+ console.log('API Response:', response);
+
+ // Check for any error-like fields in the response object
+ const responseObj = response as unknown as Record;
+ const errorFields = Object.keys(responseObj).filter(key =>
+ key.toLowerCase().includes('error') ||
+ key.toLowerCase().includes('warning') ||
+ key.toLowerCase().includes('message')
+ );
+
+ if (errorFields.length > 0) {
+ const errorValues = errorFields
+ .map(field => ({ field, value: responseObj[field] }))
+ .filter(({ value }) => value && typeof value === 'string' && value.trim() !== '');
+
+ if (errorValues.length > 0) {
+ console.warn('Additional error fields found in response:', errorValues);
+ // Log but don't automatically show these as errors unless they're critical
+ }
+ }
+
+ // Check if the API returned an error in the response
+ if (response.error) {
+ // Handle specific error cases with user-friendly messages
+ if (response.error.toLowerCase().includes('no dtrs found')) {
+ setError(`No Digital Twin Registries found for partner "${bpnl}". Please verify the BPNL is correct and if the partner has a Connector (with a reachable DTR) connected in the same dataspace as you.`);
+ } else {
+ setError(`Search failed: ${response.error}`);
+ }
+ setIsLoading(false);
+ return;
+ }
+
+ // Check if no shell descriptors were found
+ if (!response.shellDescriptors || response.shellDescriptors.length === 0) {
+ setError('No digital twins found for the specified criteria. Please try different search parameters.');
+ setIsLoading(false);
+ return;
+ }
+
+ // Check for errors in DTR statuses
+ if (response.dtrs && response.dtrs.length > 0) {
+ const errorDtrs = response.dtrs.filter(dtr =>
+ dtr.status && (
+ dtr.status.toLowerCase().includes('error') ||
+ dtr.status.toLowerCase().includes('failed') ||
+ dtr.status.toLowerCase().includes('timeout') ||
+ dtr.status.toLowerCase().includes('unavailable')
+ )
+ );
+ if (errorDtrs.length > 0) {
+ console.warn('DTR errors found:', errorDtrs);
+ const errorMessages = errorDtrs.map(dtr => `DTR ${dtr.connectorUrl}: ${dtr.status}`);
+ setError(`DTR issues detected: ${errorMessages.join(', ')}`);
+ // Don't return here - continue processing in case there are still valid results
+ }
+ }
+
+ // Create paginator
+ const digitalTwinType = partType === 'Catalog' ? 'PartType' : 'PartInstance';
+ const newPaginator = new ShellDiscoveryPaginator(
+ response,
+ bpnl,
+ digitalTwinType,
+ limit
);
+ setPaginator(newPaginator);
+
+ // Create DTR mapping if DTRs are available
+ const shellToDtrMap = response.dtrs ? createShellToDtrMap(response.dtrs as unknown as Array & { shells?: string[] }>) : undefined;
+
+ // Process results based on part type
+ if (partType === 'Catalog') {
+ const cards = convertToPartCards(response.shellDescriptors, shellToDtrMap);
+ setPartTypeCards(cards);
+ setSerializedParts([]);
+ } else {
+ const serialized = convertToSerializedParts(response.shellDescriptors, shellToDtrMap);
+ setSerializedParts(serialized);
+ setPartTypeCards([]);
+ }
+
+ setCurrentPage(response.pagination?.page || 1);
+ // Calculate total pages (this would ideally come from the API)
+ if (limit === undefined) {
+ setTotalPages(1); // No pagination when no limit is set
+ } else {
+ setTotalPages(Math.ceil(response.shellsFound / limit));
+ }
- mappedFakeParts.reverse();
+ // Mark that search has been performed successfully
+ setHasSearched(true);
+
+ } catch (err) {
+ console.error('Search error:', err);
+
+ // Extract meaningful error message from different error types
+ let errorMessage = 'Error searching for parts. Please try again.';
+
+ if (err instanceof Error) {
+ // Handle standard Error objects
+ errorMessage = `Search failed: ${err.message}`;
+ } else if (typeof err === 'string') {
+ // Handle string errors
+ errorMessage = `Search failed: ${err}`;
+ } else if (err && typeof err === 'object') {
+ // Handle axios or other structured errors
+ if ('response' in err && err.response) {
+ // Axios error with response
+ const axiosErr = err as { response: { data?: { error?: string; message?: string }; status: number; statusText: string } };
+ if (axiosErr.response.data?.error) {
+ errorMessage = `API Error: ${axiosErr.response.data.error}`;
+ } else if (axiosErr.response.data?.message) {
+ errorMessage = `API Error: ${axiosErr.response.data.message}`;
+ } else if (axiosErr.response.statusText) {
+ errorMessage = `HTTP ${axiosErr.response.status}: ${axiosErr.response.statusText}`;
+ } else {
+ errorMessage = `HTTP Error ${axiosErr.response.status}`;
+ }
+ } else if ('message' in err) {
+ // Object with message property
+ const errWithMessage = err as { message: string };
+ errorMessage = `Search failed: ${errWithMessage.message}`;
+ }
+ }
+
+ setError(errorMessage);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handlePageChange = async (_: React.ChangeEvent, page: number) => {
+ if (!paginator || page === currentPage) return;
+
+ // Determine direction and set appropriate loading state
+ const isNext = page === currentPage + 1;
+ const isPrevious = page === currentPage - 1;
+
+ if (isNext) {
+ setIsLoadingNext(true);
+ } else if (isPrevious) {
+ setIsLoadingPrevious(true);
+ }
+
+ setError(null);
+
+ try {
+ let newResponse: ShellDiscoveryResponse | null = null;
+
+ // Handle sequential navigation (most common case)
+ if (page === currentPage + 1 && paginator.hasNext()) {
+ newResponse = await paginator.next();
+ } else if (page === currentPage - 1 && paginator.hasPrevious()) {
+ newResponse = await paginator.previous();
+ } else {
+ // For non-sequential navigation, show a helpful message
+ // Cursor-based pagination doesn't support random page access efficiently
+ setError(`Direct navigation to page ${page} is not supported. Please use next/previous navigation.`);
+ return;
+ }
+
+ if (newResponse) {
+ // Check if the pagination response contains an error
+ if (newResponse.error) {
+ if (newResponse.error.toLowerCase().includes('no dtrs found')) {
+ setError(`No Digital Twin Registries found for partner "${bpnl}" on page ${page}. Please verify the BPNL is correct and the partner has registered digital twins.`);
+ } else {
+ setError(`Pagination failed: ${newResponse.error}`);
+ }
+ return;
+ }
+
+ setCurrentResponse(newResponse);
+ setCurrentPage(newResponse.pagination?.page || currentPage);
+
+ // Create DTR mapping if DTRs are available
+ const shellToDtrMap = newResponse.dtrs ? createShellToDtrMap(newResponse.dtrs as unknown as Array & { shells?: string[] }>) : undefined;
+
+ // Update results based on part type
+ if (partType === 'Catalog') {
+ const cards = convertToPartCards(newResponse.shellDescriptors, shellToDtrMap);
+ setPartTypeCards(cards);
+ } else {
+ const serialized = convertToSerializedParts(newResponse.shellDescriptors, shellToDtrMap);
+ setSerializedParts(serialized);
+ }
+ } else {
+ setError('No more pages available in that direction.');
+ }
+ } catch (err) {
+ console.error('Pagination error:', err);
+
+ // Extract meaningful error message from pagination errors
+ let errorMessage = 'Error loading page. Please try again.';
+
+ if (err instanceof Error) {
+ errorMessage = `Pagination failed: ${err.message}`;
+ } else if (typeof err === 'string') {
+ errorMessage = `Pagination failed: ${err}`;
+ } else if (err && typeof err === 'object') {
+ if ('response' in err && err.response) {
+ const axiosErr = err as { response: { data?: { error?: string; message?: string }; status: number; statusText: string } };
+ if (axiosErr.response.data?.error) {
+ errorMessage = `Pagination API Error: ${axiosErr.response.data.error}`;
+ } else if (axiosErr.response.data?.message) {
+ errorMessage = `Pagination API Error: ${axiosErr.response.data.message}`;
+ } else {
+ errorMessage = `Pagination HTTP ${axiosErr.response.status}: ${axiosErr.response.statusText}`;
+ }
+ } else if ('message' in err) {
+ const errWithMessage = err as { message: string };
+ errorMessage = `Pagination failed: ${errWithMessage.message}`;
+ }
+ }
+
+ setError(errorMessage);
+ } finally {
+ // Clean up pagination loading states
+ setIsLoadingNext(false);
+ setIsLoadingPrevious(false);
+ }
+ };
+
+ const handleCardClick = (partId: string) => {
+ console.log('Card clicked:', partId);
+
+ // Find the card data to get the raw twin data
+ const card = partTypeCards.find(c => c.id === partId || `${c.manufacturerId}/${c.manufacturerPartId}` === partId);
+ if (card && card.rawTwinData) {
+ // Get DTR information if available
+ let dtrInfo = undefined;
+ if (currentResponse?.dtrs && card.dtrIndex !== undefined && currentResponse.dtrs[card.dtrIndex]) {
+ const dtr = currentResponse.dtrs[card.dtrIndex];
+ dtrInfo = {
+ connectorUrl: dtr.connectorUrl || 'Unknown',
+ assetId: dtr.assetId || card.id
+ };
+ }
+
+ // Convert the raw twin data to the format expected by SingleTwinResult
+ const twinResult: SingleShellDiscoveryResponse = {
+ shell_descriptor: {
+ ...card.rawTwinData,
+ idShort: card.rawTwinData.idShort || card.rawTwinData.id, // Use actual idShort if available, otherwise fallback to AAS ID
+ },
+ dtr: dtrInfo || {
+ connectorUrl: 'Local Discovery',
+ assetId: card.id
+ }
+ };
+
+ setViewingTwin(twinResult);
+ setSearchMode('view');
+ }
+ };
- setParts(mappedFakeParts);
- } catch (error) {
- console.error('Error al buscar:', error);
- alert('Error');
+ const handleSerializedPartView = (part: SerializedPartData) => {
+ console.log('View serialized part:', part);
+
+ if (part.rawTwinData) {
+ // Get DTR information if available
+ let dtrInfo = undefined;
+ if (currentResponse?.dtrs && part.dtrIndex !== undefined && currentResponse.dtrs[part.dtrIndex]) {
+ const dtr = currentResponse.dtrs[part.dtrIndex];
+ dtrInfo = {
+ connectorUrl: dtr.connectorUrl || 'Unknown',
+ assetId: dtr.assetId || part.aasId
+ };
+ }
+
+ // Convert the raw twin data to the format expected by SingleTwinResult
+ const twinResult: SingleShellDiscoveryResponse = {
+ shell_descriptor: {
+ ...part.rawTwinData,
+ idShort: part.rawTwinData.idShort || part.rawTwinData.id, // Use actual idShort if available, otherwise fallback to AAS ID
+ },
+ dtr: dtrInfo || {
+ connectorUrl: 'Local Discovery',
+ assetId: part.aasId
+ }
+ };
+
+ setViewingTwin(twinResult);
+ setSearchMode('view');
}
};
+ const handleRegisterClick = (manufacturerId: string, manufacturerPartId: string) => {
+ console.log('Register part:', manufacturerId, manufacturerPartId);
+ // Implement registration functionality
+ };
+
return (
-
- {/* Sidebar */}
-
- Part Type
- setPartType(e.target.value)}>
- } label="Part Type" />
- } label="Serialized" />
-
-
-
-
- Nº Parts
- {
- if (typeof val === 'number') {
- setNumParts(val);
- }
- }}
- valueLabelDisplay="auto"
- />
+
+ {/* Compact Header - shown when search results are displayed */}
+ {hasSearched && (
+
+
+
+
+ {searchMode === 'view' ? (
+
+ ) : (
+ }
+ size="small"
+ sx={{
+ borderColor: 'primary.main',
+ color: 'primary.main',
+ '&:hover': {
+ backgroundColor: 'primary.main',
+ color: 'white',
+ borderColor: 'primary.main'
+ }
+ }}
+ >
+ New Search
+
+ )}
+
+
+
+
+ Dataspace Discovery
+
+
+
+
+
+
+ {getCompanyName(bpnl)}
+
+
+ {bpnl}
+
+
+
+
-
-
- {numParts}
-
-
-
-
-
- {/* Main Content */}
-
-
- Parts Discovery
-
-
- {/* Search fields */}
-
-
- setBpnl(e.target.value)}
- slotProps={{
- input: {
- endAdornment: (
-
-
-
-
-
- ),
- },
+
+ )}
+
+ {/* Main Content Container */}
+
+ {/* Search Mode Toggle */}
+ {!hasSearched && (
+
+ {/* Display Filters Button - Only show in Discovery mode when sidebar should be available but is hidden */}
+ {searchMode === 'discovery' && !isVisible && (
+ }
+ sx={{
+ color: 'rgba(25, 118, 210, 0.8)',
+ fontSize: '0.8rem',
+ textTransform: 'none',
+ fontWeight: 500,
+ py: 0.3,
+ px: 0.8,
+ minHeight: '22px',
+ '&:hover': {
+ backgroundColor: 'rgba(25, 118, 210, 0.08)',
+ color: '#1976d2'
+ },
+ '& .MuiButton-startIcon': {
+ marginRight: '4px',
+ '& > svg': {
+ fontSize: '14px'
+ }
+ }
+ }}
+ >
+ Display Filters
+
+ )}
+
+ {/* Hide Filters Button - Only show in Discovery mode when sidebar is visible */}
+ {searchMode === 'discovery' && isVisible && (
+ }
+ sx={{
+ color: 'rgba(25, 118, 210, 0.8)',
+ fontSize: '0.8rem',
+ textTransform: 'none',
+ fontWeight: 500,
+ py: 0.3,
+ px: 0.8,
+ minHeight: '22px',
+ '&:hover': {
+ backgroundColor: 'rgba(25, 118, 210, 0.08)',
+ color: '#1976d2'
+ },
+ '& .MuiButton-startIcon': {
+ marginRight: '4px',
+ '& > svg': {
+ fontSize: '14px'
+ }
+ }
+ }}
+ >
+ Hide Filters
+
+ )}
+
+ {/* Mode Toggle */}
+
+ setSearchMode('discovery')}
+ >
+ Discovery Mode
+
+
-
-
-
-
-
-
-
- ),
- },
+ onClick={() => setSearchMode(searchMode === 'discovery' ? 'single' : 'discovery')}
+ >
+
+
+
-
-
-
-
-
-
- {/* Parts grid */}
-
- {partType === 'Serialized' ? (
-
-
-
- ) : (
- {}}
- onShare={() => {}}
- onMore={() => {}}
- onRegisterClick={() => {}}
- items={parts.slice(0, numParts).map((part) => ({
- manufacturerId: part.manufacturerId,
- manufacturerPartId: part.manufacturerPartId,
- name: part.name,
- category: part.category,
- status: part.status,
- }))}
- isLoading={false}
- isDiscovery={true}
- />
+ Single Twin
+
+
+
)}
-
-
-
+
+ {/* Main Content */}
+
+ {/* Centered Welcome Screen - only shown when no search has been performed and in discovery mode */}
+ {!hasSearched && searchMode === 'discovery' && (
+
+
+ Dataspace Discovery
+
+
+ Discover and explore digital twin parts in a Tractus-X network
+
+
+ {/* Centered Search Card */}
+
+
+ {
+ if (typeof option === 'string') return option;
+ return `${option.name} - ${option.bpnl}`;
+ }}
+ value={bpnl}
+ onChange={(_, newValue) => {
+ if (typeof newValue === 'string') {
+ // Custom BPNL entered
+ setBpnl(newValue);
+ setSelectedPartner(null);
+ } else if (newValue) {
+ // Partner selected from dropdown
+ setBpnl(newValue.bpnl);
+ setSelectedPartner(newValue);
+ } else {
+ // Cleared
+ setBpnl('');
+ setSelectedPartner(null);
+ }
+ }}
+ onInputChange={(_, newInputValue) => {
+ setBpnl(newInputValue);
+ if (!availablePartners.find(p => p.bpnl === newInputValue)) {
+ setSelectedPartner(null);
+ }
+ }}
+ renderInput={(params) => (
+
+ {isLoadingPartners ? : null}
+ {params.InputProps.endAdornment}
+
+
+
+
+
+ >
+ ),
+ },
+ }}
+ sx={{
+ '& .MuiOutlinedInput-root': {
+ borderRadius: 3,
+ fontSize: '1.1rem',
+ backgroundColor: 'rgba(255, 255, 255, 0.8)',
+ '&:hover': {
+ backgroundColor: 'rgba(255, 255, 255, 0.9)'
+ },
+ '&.Mui-focused': {
+ backgroundColor: 'white'
+ }
+ },
+ '& .MuiInputLabel-root': {
+ fontSize: '1.1rem'
+ }
+ }}
+ />
+ )}
+ renderOption={(props, option) => (
+
+
+ {option.name}
+
+
+ {option.bpnl}
+
+
+ )}
+ loading={isLoadingPartners}
+ loadingText="Loading partners..."
+ noOptionsText="No partners found. You can still enter a custom BPNL."
+ sx={{ width: '100%' }}
+ />
+
+ : }
+ sx={{
+ py: 2,
+ borderRadius: 3,
+ fontSize: '1.2rem',
+ fontWeight: '600',
+ textTransform: 'none',
+ background: 'linear-gradient(45deg, #1976d2 30%, #42a5f5 90%)',
+ boxShadow: '0 8px 25px rgba(25, 118, 210, 0.3)',
+ '&:hover': {
+ background: 'linear-gradient(45deg, #1565c0 30%, #2196f3 90%)',
+ boxShadow: '0 12px 35px rgba(25, 118, 210, 0.4)',
+ transform: 'translateY(-1px)'
+ },
+ '&:disabled': {
+ background: '#e0e0e0',
+ boxShadow: 'none'
+ }
+ }}
+ >
+ {isLoading ? 'Searching...' : 'Start Discovery'}
+
+
+
+
+ )}
+
+ {/* Single Twin Search Screen - only shown when no search has been performed and in single mode */}
+ {!hasSearched && searchMode === 'single' && (
+
+
+ Single Digital Twin
+
+
+ Search for a specific digital twin by providing its Asset Administration Shell (AAS) ID
+
+
+ {/* Centered Search Card */}
+
+
+ {
+ if (typeof option === 'string') return option;
+ return `${option.name} - ${option.bpnl}`;
+ }}
+ value={bpnl}
+ onChange={(_, newValue) => {
+ if (typeof newValue === 'string') {
+ // Custom BPNL entered
+ setBpnl(newValue);
+ setSelectedPartner(null);
+ } else if (newValue) {
+ // Partner selected from dropdown
+ setBpnl(newValue.bpnl);
+ setSelectedPartner(newValue);
+ } else {
+ // Cleared
+ setBpnl('');
+ setSelectedPartner(null);
+ }
+ }}
+ onInputChange={(_, newInputValue) => {
+ setBpnl(newInputValue);
+ if (!availablePartners.find(p => p.bpnl === newInputValue)) {
+ setSelectedPartner(null);
+ }
+ }}
+ renderInput={(params) => (
+
+ {isLoadingPartners ? : null}
+ {params.InputProps.endAdornment}
+ >
+ ),
+ },
+ }}
+ sx={{
+ '& .MuiOutlinedInput-root': {
+ borderRadius: 3,
+ fontSize: '1.1rem',
+ backgroundColor: 'rgba(255, 255, 255, 0.8)',
+ '&:hover': {
+ backgroundColor: 'rgba(255, 255, 255, 0.9)'
+ },
+ '&.Mui-focused': {
+ backgroundColor: 'white'
+ }
+ },
+ '& .MuiInputLabel-root': {
+ fontSize: '1.1rem'
+ }
+ }}
+ />
+ )}
+ renderOption={(props, option) => (
+
+
+ {option.name}
+
+
+ {option.bpnl}
+
+
+ )}
+ loading={isLoadingPartners}
+ loadingText="Loading partners..."
+ noOptionsText="No partners found. You can still enter a custom BPNL."
+ sx={{ width: '100%' }}
+ />
+
+ {/* AAS ID Field */}
+ ) => setSingleTwinAasId(e.target.value)}
+ error={!!error && !singleTwinAasId.trim()}
+ helperText={
+ !!error && !singleTwinAasId.trim()
+ ? 'AAS ID is required'
+ : 'Enter the unique identifier for the Asset Administration Shell'
+ }
+ sx={{
+ '& .MuiOutlinedInput-root': {
+ borderRadius: 3,
+ fontSize: '1.1rem',
+ backgroundColor: 'rgba(255, 255, 255, 0.8)',
+ '&:hover': {
+ backgroundColor: 'rgba(255, 255, 255, 0.9)'
+ },
+ '&.Mui-focused': {
+ backgroundColor: 'white'
+ }
+ },
+ '& .MuiInputLabel-root': {
+ fontSize: '1.1rem'
+ }
+ }}
+ />
+
+
+ : }
+ sx={{
+ py: 2,
+ borderRadius: 3,
+ fontSize: '1.2rem',
+ fontWeight: '600',
+ textTransform: 'none',
+ background: 'linear-gradient(45deg, #1976d2 30%, #42a5f5 90%)',
+ boxShadow: '0 8px 25px rgba(25, 118, 210, 0.3)',
+ '&:hover': {
+ background: 'linear-gradient(45deg, #1565c0 30%, #2196f3 90%)',
+ boxShadow: '0 12px 35px rgba(25, 118, 210, 0.4)',
+ transform: 'translateY(-1px)'
+ },
+ '&:disabled': {
+ background: '#e0e0e0',
+ boxShadow: 'none'
+ }
+ }}
+ >
+ {isLoading ? 'Searching...' : 'Search Digital Twin'}
+
+
+
+
+ )}
+
+ {/* Error Alert */}
+ {error && (
+
+ setError(null)} sx={{ maxWidth: '600px' }}>
+ {error}
+
+
+ )}
+
+ {/* Results Section - shown when search has been performed */}
+ {hasSearched && (
+
+ {/* Single Twin Mode Results - Outside Results Display to avoid padding inheritance */}
+ {singleTwinResult && searchMode === 'single' && (
+
+ )}
+
+ {/* View Twin Mode Results - For viewing twins from catalog */}
+ {viewingTwin && searchMode === 'view' && (
+
+
+
+ )}
+ {/* Discovery Mode Results */}
+ {currentResponse && searchMode === 'discovery' && (
+
+ {/* Left Side - Part Type Indicator + Active Filters */}
+
+ {/* Part Type - Always shown */}
+
+
+ {partType === 'Catalog' ? 'Catalog Parts' : 'Serialized Parts'}
+
+
+
+ {/* Active Filters - Only shown when there are filters */}
+ {getActiveFilterChips().length > 0 && (
+ <>
+
+ Active Filters:
+
+ {getActiveFilterChips()}
+ >
+ )}
+
+
+ {/* Results Count - Right Side */}
+
+ {/* Results Count */}
+
+
+ {currentResponse.shellsFound}
+
+
+
+
+ )}
+
+ {/* Remove the duplicate simple results count section since we now handle both cases above */}
+
+ {/* DTR Information Section - Discovery Mode */}
+ {currentResponse && currentResponse.dtrs && searchMode === 'discovery' && (
+
+ setDtrSectionVisible(!dtrSectionVisible)}
+ >
+
+ Digital Twin Registries ({currentResponse.dtrs.length})
+
+
+ {dtrSectionVisible ? : }
+
+
+
+ {dtrSectionVisible && (
+
+ {/* DTR Carousel */}
+ {currentResponse.dtrs.length > 0 && (
+
+ {/* Carousel Navigation Header */}
+ {currentResponse.dtrs.length > dtrItemsPerSlide && (
+
+
+ {Math.floor(dtrCarouselIndex / dtrItemsPerSlide) + 1} of {Math.ceil(currentResponse.dtrs.length / dtrItemsPerSlide)} • {currentResponse.dtrs.length} total
+
+
+
+
+
+ = currentResponse.dtrs.length - dtrItemsPerSlide}
+ sx={{
+ color: dtrCarouselIndex >= currentResponse.dtrs.length - dtrItemsPerSlide ? 'text.disabled' : 'primary.main',
+ '&:hover': { backgroundColor: 'primary.light' }
+ }}
+ >
+
+
+
+
+ )}
+
+ {/* DTR Cards Grid */}
+
+ {currentResponse.dtrs
+ .slice(dtrCarouselIndex, dtrCarouselIndex + dtrItemsPerSlide)
+ .map((dtr, relativeIndex) => {
+ const actualIndex = dtrCarouselIndex + relativeIndex;
+ const dtrColor = getDtrColor(actualIndex);
+ return (
+
+ {/* DTR Header */}
+
+
+
+
+
+
+
+
+ {/* Shells Found - Top Right Corner */}
+
+
+ Shells Found
+
+
+
+
+
+ {/* Connector URL and Asset ID Grid */}
+
+
+
+ Connector URL:
+
+
+
+ {dtr.connectorUrl}
+
+ handleCopyConnectorUrl(dtr.connectorUrl, actualIndex)}
+ sx={{
+ p: 0.5,
+ minWidth: 'auto',
+ '&:hover': { backgroundColor: 'rgba(0,0,0,0.1)' }
+ }}
+ >
+ {copiedConnectorUrl === `${actualIndex}-${dtr.connectorUrl}` ?
+ :
+
+ }
+
+
+
+
+
+
+ Asset ID:
+
+
+
+ {dtr.assetId}
+
+ handleCopyAssetId(dtr.assetId, actualIndex)}
+ sx={{
+ p: 0.5,
+ minWidth: 'auto',
+ '&:hover': { backgroundColor: 'rgba(0,0,0,0.1)' }
+ }}
+ >
+ {copiedAssetId === `${actualIndex}-${dtr.assetId}` ?
+ :
+
+ }
+
+
+
+
+
+ );
+ })}
+
+
+ {/* Carousel Indicators */}
+ {currentResponse.dtrs.length > dtrItemsPerSlide && (
+
+ {Array.from({ length: Math.ceil(currentResponse.dtrs.length / dtrItemsPerSlide) }).map((_, pageIndex) => {
+ const isActive = Math.floor(dtrCarouselIndex / dtrItemsPerSlide) === pageIndex;
+ return (
+ setDtrCarouselIndex(pageIndex * dtrItemsPerSlide)}
+ sx={{
+ width: 8,
+ height: 8,
+ borderRadius: '50%',
+ backgroundColor: isActive ? 'primary.main' : 'grey.300',
+ cursor: 'pointer',
+ transition: 'all 0.2s ease',
+ '&:hover': {
+ backgroundColor: isActive ? 'primary.dark' : 'grey.400'
+ }
+ }}
+ />
+ );
+ })}
+
+ )}
+
+ )}
+
+ )}
+
+ )}
+
+ {/* Results Display */}
+ {searchMode === 'discovery' && (
+
+ {partType === 'Serialized' ? (
+ <>
+ {serializedParts.length > 0 ? (
+
+ ) : !isLoading && currentResponse ? (
+
+ No serialized parts found
+
+ ) : null}
+ >
+ ) : (
+ <>
+ {partTypeCards.length > 0 ? (
+ ({
+ id: card.id,
+ manufacturerId: card.manufacturerId,
+ manufacturerPartId: card.manufacturerPartId,
+ name: card.name,
+ category: card.category,
+ dtrIndex: card.dtrIndex,
+ shellId: card.id, // The shell ID is the same as the card ID (AAS ID)
+ rawTwinData: card.rawTwinData
+ }))}
+ isLoading={isLoading}
+ />
+ ) : !isLoading && currentResponse ? (
+
+ No catalog parts found
+
+ ) : null}
+ >
+ )}
+
+ )}
+
+
+ {/* Pagination */}
+ {currentResponse && !isLoading && pageLimit > 0 && searchMode === 'discovery' && (
+
+ {paginator?.hasPrevious() && (
+
+ )}
+
+
+
+ Page {currentPage}
+
+ {totalPages > 1 && (
+
+ of {totalPages}
+
+ )}
+
+
+ {paginator?.hasNext() && (
+
+ )}
+
+ )}
+
+
+ )}
+
+
+
);
};
diff --git a/ichub-frontend/src/routes.tsx b/ichub-frontend/src/routes.tsx
index d17ef30b..309aaeed 100644
--- a/ichub-frontend/src/routes.tsx
+++ b/ichub-frontend/src/routes.tsx
@@ -20,29 +20,93 @@
* SPDX-License-Identifier: Apache-2.0
********************************************************************************/
-
+import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import MainLayout from "./layouts/MainLayout";
-import ProductsList from './pages/ProductsList';
-import PartnersList from './pages/PartnersList';
-import ProductsDetails from './pages/ProductsDetails';
-import PartsDiscovery from "./pages/PartsDiscovery";
-import SerializedParts from "./pages/SerializedParts"
+import { CircularProgress, Box } from "@mui/material";
+
+// Lazy load page components for automatic code splitting
+const ProductsList = lazy(() => import('./pages/ProductsList'));
+const PartnersList = lazy(() => import('./pages/PartnersList'));
+const ProductsDetails = lazy(() => import('./pages/ProductsDetails'));
+const PartsDiscovery = lazy(() => import("./pages/PartsDiscovery"));
+const SerializedParts = lazy(() => import("./pages/SerializedParts"));
+
+// Loading component
+const PageLoader = () => (
+
+
+
+);
export default function AppRoutes() {
return (
} >
- } />
- } />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
{/* Here we must change the elements as we go along as we develop */}
- } />
- } />
- } />
- } />
- } />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
diff --git a/ichub-frontend/src/services/EnvironmentService.ts b/ichub-frontend/src/services/EnvironmentService.ts
index e54fb58a..308a89c7 100644
--- a/ichub-frontend/src/services/EnvironmentService.ts
+++ b/ichub-frontend/src/services/EnvironmentService.ts
@@ -20,18 +20,127 @@
* SPDX-License-Identifier: Apache-2.0
********************************************************************************/
-declare const ENV: Record
+// Types for governance configuration
+export interface GovernanceConstraint {
+ leftOperand: string;
+ operator: string;
+ rightOperand: string;
+}
+
+export interface GovernanceRule {
+ action: string;
+ LogicalConstraint?: string;
+ constraints: GovernanceConstraint[];
+}
+
+export interface GovernancePolicy {
+ strict: boolean;
+ permission: GovernanceRule | GovernanceRule[];
+ prohibition: GovernanceRule | GovernanceRule[];
+ obligation: GovernanceRule | GovernanceRule[];
+}
+
+export interface GovernanceConfig {
+ semanticid: string;
+ policies: GovernancePolicy[];
+}
export const isRequireHttpsUrlPattern = () =>
- ENV.REQUIRE_HTTPS_URL_PATTERN !== 'false';
+ import.meta.env.VITE_REQUIRE_HTTPS_URL_PATTERN !== 'false';
+
+export const getIchubBackendUrl = () => import.meta.env.VITE_ICHUB_BACKEND_URL ?? '';
+export const getParticipantId = () => import.meta.env.VITE_PARTICIPANT_ID ?? 'BPNL0000000093Q7';
-export const getIchubBackendUrl = () => ENV.ICHUB_BACKEND_URL ?? '';
-export const getParticipantId = () => ENV.PARTICIPANT_ID ?? 'BPNL000000000000';
+export const getGovernanceConfig = (): GovernanceConfig[] => {
+ try {
+ // First try to get from window.ENV (runtime injection), then fallback to import.meta.env
+ const configStr = window?.ENV?.GOVERNANCE_CONFIG || import.meta.env.VITE_GOVERNANCE_CONFIG;
+ if (!configStr) return [];
+ return JSON.parse(configStr) as GovernanceConfig[];
+ } catch (error) {
+ console.warn('Failed to parse governance configuration:', error);
+ return [];
+ }
+};
+
+export const getDtrPoliciesConfig = (): GovernancePolicy[] => {
+ try {
+ // First try to get from window.ENV (runtime injection), then fallback to import.meta.env
+ const configStr = window?.ENV?.DTR_POLICIES_CONFIG || import.meta.env.VITE_DTR_POLICIES_CONFIG;
+ if (!configStr) {
+ // Return default DTR policies if no configuration is provided
+ return [{
+ strict: false,
+ permission: {
+ action: 'odrl:use',
+ LogicalConstraint: 'odrl:and',
+ constraints: [
+ {
+ leftOperand: 'cx-policy:FrameworkAgreement',
+ operator: 'odrl:eq',
+ rightOperand: 'DataExchangeGovernance:1.0'
+ },
+ {
+ leftOperand: 'cx-policy:Membership',
+ operator: 'odrl:eq',
+ rightOperand: 'active'
+ },
+ {
+ leftOperand: 'cx-policy:UsagePurpose',
+ operator: 'odrl:eq',
+ rightOperand: 'cx.core.digitalTwinRegistry:1'
+ }
+ ]
+ },
+ prohibition: [],
+ obligation: []
+ }];
+ }
+ return JSON.parse(configStr) as GovernancePolicy[];
+ } catch (error) {
+ console.warn('Failed to parse DTR policies configuration:', error);
+ // Return default DTR policies on error
+ return [{
+ strict: false,
+ permission: {
+ action: 'odrl:use',
+ LogicalConstraint: 'odrl:and',
+ constraints: [
+ {
+ leftOperand: 'cx-policy:FrameworkAgreement',
+ operator: 'odrl:eq',
+ rightOperand: 'DataExchangeGovernance:1.0'
+ },
+ {
+ leftOperand: 'cx-policy:Membership',
+ operator: 'odrl:eq',
+ rightOperand: 'active'
+ },
+ {
+ leftOperand: 'cx-policy:UsagePurpose',
+ operator: 'odrl:eq',
+ rightOperand: 'cx.core.digitalTwinRegistry:1'
+ }
+ ]
+ },
+ prohibition: {
+ action: 'odrl:prohibit',
+ constraints: []
+ },
+ obligation: {
+ action: 'odrl:compensate',
+ constraints: []
+ }
+ }];
+ }
+};
const EnvironmentService = {
isRequireHttpsUrlPattern,
getIchubBackendUrl,
- getParticipantId
+ getParticipantId,
+ getGovernanceConfig,
+ getDtrPoliciesConfig
};
export default EnvironmentService;
diff --git a/ichub-frontend/src/types/product.ts b/ichub-frontend/src/types/product.ts
index 16924b81..812cd473 100644
--- a/ichub-frontend/src/types/product.ts
+++ b/ichub-frontend/src/types/product.ts
@@ -56,6 +56,14 @@ export interface PartType {
customerPartIds?: Record; // e.g., { "CUSTOMER_BPNL_XYZ": { name: "BMW", bpnl: "BPNL00000003CRHK" } }
}
+
+export interface DiscoveryPartType {
+ manufacturerId: string;
+ manufacturerPartId: string;
+ customerPartId: string,
+ id: string,
+ globalAssetId: string,
+}
export type ApiPartData = Omit & {
status: number; // Status from API is a number
};
\ No newline at end of file
diff --git a/ichub-frontend/src/utils/governancePolicyUtils.ts b/ichub-frontend/src/utils/governancePolicyUtils.ts
new file mode 100644
index 00000000..cc53c1c9
--- /dev/null
+++ b/ichub-frontend/src/utils/governancePolicyUtils.ts
@@ -0,0 +1,262 @@
+/********************************************************************************
+ * Eclipse Tractus-X - Industry Core Hub Frontend
+ *
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0.
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the
+ * License for the specific language govern in permissions and limitations
+ * under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+********************************************************************************/
+
+import type { GovernanceConfig, GovernanceConstraint, GovernanceRule } from '../services/EnvironmentService';
+
+// ODRL constraint interface for API compatibility
+export interface OdrlConstraint {
+ "odrl:leftOperand": { "@id": string };
+ "odrl:operator": { "@id": string };
+ "odrl:rightOperand": string;
+}
+
+// ODRL rule interface for API compatibility
+export interface OdrlRule {
+ "odrl:action": { "@id": string };
+ "odrl:constraint"?: {
+ "odrl:and"?: OdrlConstraint[];
+ "odrl:or"?: OdrlConstraint[];
+ } | OdrlConstraint; // Single constraint or logical constraint group
+}
+
+// ODRL policy interface for API compatibility
+export interface OdrlPolicy {
+ "odrl:permission": OdrlRule;
+ "odrl:prohibition": OdrlRule[];
+ "odrl:obligation": OdrlRule[];
+}
+
+/**
+ * Convert governance constraint to ODRL format
+ */
+function convertToOdrlConstraint(constraint: GovernanceConstraint): OdrlConstraint {
+ return {
+ "odrl:leftOperand": { "@id": constraint.leftOperand },
+ "odrl:operator": { "@id": constraint.operator },
+ "odrl:rightOperand": constraint.rightOperand
+ };
+}
+
+/**
+ * Generate all permutations of an array
+ */
+function generatePermutations(arr: T[]): T[][] {
+ if (arr.length <= 1) return [arr];
+
+ const result: T[][] = [];
+ for (let i = 0; i < arr.length; i++) {
+ const rest = [...arr.slice(0, i), ...arr.slice(i + 1)];
+ const perms = generatePermutations(rest);
+ for (const perm of perms) {
+ result.push([arr[i], ...perm]);
+ }
+ }
+ return result;
+}
+
+/**
+ * Generate all combinations (subsets) of constraints
+ */
+function generateCombinations(arr: T[]): T[][] {
+ const result: T[][] = [];
+
+ // Generate all possible combinations (1 to n elements)
+ for (let size = 1; size <= arr.length; size++) {
+ const combinations = getCombinationsOfSize(arr, size);
+ result.push(...combinations);
+ }
+
+ return result;
+}
+
+/**
+ * Get all combinations of a specific size
+ */
+function getCombinationsOfSize(arr: T[], size: number): T[][] {
+ if (size === 1) return arr.map(item => [item]);
+ if (size === arr.length) return [arr];
+
+ const result: T[][] = [];
+
+ for (let i = 0; i <= arr.length - size; i++) {
+ const smallerCombinations = getCombinationsOfSize(arr.slice(i + 1), size - 1);
+ for (const combination of smallerCombinations) {
+ result.push([arr[i], ...combination]);
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Generate all permutations of all combinations
+ */
+function generateAllVariants(arr: T[]): T[][] {
+ const combinations = generateCombinations(arr);
+ const result: T[][] = [];
+
+ for (const combination of combinations) {
+ const permutations = generatePermutations(combination);
+ result.push(...permutations);
+ }
+
+ return result;
+}
+
+/**
+ * Create ODRL constraint structure based on logical constraint type and constraints
+ */
+function createOdrlConstraintStructure(
+ odrlConstraints: OdrlConstraint[],
+ logicalConstraint?: string
+): OdrlRule["odrl:constraint"] {
+ // Single constraint - no logical wrapper needed
+ if (odrlConstraints.length === 1) {
+ return odrlConstraints[0];
+ }
+
+ // Multiple constraints - use logical constraint (default to AND)
+ const logic = logicalConstraint?.toLowerCase() === 'odrl:or' ? 'odrl:or' : 'odrl:and';
+
+ if (logic === 'odrl:or') {
+ return { "odrl:or": odrlConstraints };
+ } else {
+ return { "odrl:and": odrlConstraints };
+ }
+}
+
+/**
+ * Process rules (permission, prohibition, or obligation) and generate ODRL constraints
+ */
+function processRules(
+ rules: GovernanceRule[],
+ ruleType: 'permission' | 'prohibition' | 'obligation',
+ isStrict: boolean
+): OdrlPolicy[] {
+ if (!rules || rules.length === 0) {
+ return [];
+ }
+
+ const result: OdrlPolicy[] = [];
+
+ for (const rule of rules) {
+ if (!rule.constraints || rule.constraints.length === 0) {
+ continue;
+ }
+
+ // Convert constraints to ODRL format
+ const odrlConstraints = rule.constraints.map(convertToOdrlConstraint);
+
+ if (isStrict) {
+ // Strict mode: use exact order as configured
+ const odrlRule: OdrlRule = {
+ "odrl:action": { "@id": rule.action },
+ "odrl:constraint": createOdrlConstraintStructure(odrlConstraints, rule.LogicalConstraint)
+ };
+
+ const policy: OdrlPolicy = {
+ "odrl:permission": ruleType === 'permission' ? odrlRule : { "odrl:action": { "@id": "odrl:use" } },
+ "odrl:prohibition": ruleType === 'prohibition' ? [odrlRule] : [],
+ "odrl:obligation": ruleType === 'obligation' ? [odrlRule] : []
+ };
+
+ result.push(policy);
+ } else {
+ // Non-strict mode: generate all permutations of all combinations
+ const variants = generateAllVariants(odrlConstraints);
+
+ for (const variant of variants) {
+ const odrlRule: OdrlRule = {
+ "odrl:action": { "@id": rule.action },
+ "odrl:constraint": createOdrlConstraintStructure(variant, rule.LogicalConstraint)
+ };
+
+ const policy: OdrlPolicy = {
+ "odrl:permission": ruleType === 'permission' ? odrlRule : { "odrl:action": { "@id": "odrl:use" } },
+ "odrl:prohibition": ruleType === 'prohibition' ? [odrlRule] : [],
+ "odrl:obligation": ruleType === 'obligation' ? [odrlRule] : []
+ };
+
+ result.push(policy);
+ }
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Convert governance policy to ODRL policies with permutations
+ */
+export function generateOdrlPolicies(
+ governanceConfig: GovernanceConfig[],
+ semanticId?: string
+): OdrlPolicy[] {
+ // Find the matching governance config
+ const config = semanticId
+ ? governanceConfig.find(c => c.semanticid === semanticId)
+ : governanceConfig[0]; // Use first if no semantic ID specified
+
+ if (!config || !config.policies || config.policies.length === 0) {
+ return [];
+ }
+
+ const result: OdrlPolicy[] = [];
+
+ for (const policy of config.policies) {
+ // Process permissions
+ const permissionRules = Array.isArray(policy.permission) ? policy.permission : [policy.permission];
+ const permissionPolicies = processRules(permissionRules, 'permission', policy.strict);
+ result.push(...permissionPolicies);
+
+ // Process prohibitions
+ const prohibitionRules = Array.isArray(policy.prohibition) ? policy.prohibition : [policy.prohibition];
+ const prohibitionPolicies = processRules(prohibitionRules, 'prohibition', policy.strict);
+ result.push(...prohibitionPolicies);
+
+ // Process obligations
+ const obligationRules = Array.isArray(policy.obligation) ? policy.obligation : [policy.obligation];
+ const obligationPolicies = processRules(obligationRules, 'obligation', policy.strict);
+ result.push(...obligationPolicies);
+ }
+
+ return result;
+}
+
+/**
+ * Get governance policy for a specific semantic ID
+ */
+export function getGovernancePolicyForSemanticId(
+ governanceConfig: GovernanceConfig[],
+ semanticId: string
+): OdrlPolicy[] {
+ return generateOdrlPolicies(governanceConfig, semanticId);
+}
+
+/**
+ * Get default governance policy (first configuration)
+ */
+export function getDefaultGovernancePolicy(
+ governanceConfig: GovernanceConfig[]
+): OdrlPolicy[] {
+ return generateOdrlPolicies(governanceConfig);
+}
diff --git a/ichub-frontend/src/vite-env.d.ts b/ichub-frontend/src/vite-env.d.ts
index a48a8541..40a1887b 100644
--- a/ichub-frontend/src/vite-env.d.ts
+++ b/ichub-frontend/src/vite-env.d.ts
@@ -21,3 +21,14 @@
********************************************************************************/
///
+
+declare global {
+ interface Window {
+ ENV?: {
+ GOVERNANCE_CONFIG?: string;
+ DTR_POLICIES_CONFIG?: string;
+ }
+ }
+}
+
+export {};
diff --git a/ichub-frontend/vite.config.ts b/ichub-frontend/vite.config.ts
index 7fcc3298..d9fb21d6 100644
--- a/ichub-frontend/vite.config.ts
+++ b/ichub-frontend/vite.config.ts
@@ -27,9 +27,82 @@ import react from '@vitejs/plugin-react-swc'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
+
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
-});
+ build: {
+ // Optimize build performance and chunk sizes
+ target: 'esnext',
+ minify: 'esbuild',
+
+ // Increase chunk size warning limit to 1MB (from default 500kB)
+ chunkSizeWarningLimit: 1000,
+
+ // Optimize for faster builds in Docker
+ reportCompressedSize: false, // Skip gzip size reporting for faster builds
+
+ rollupOptions: {
+ output: {
+ // Automatic chunking based on file patterns - no manual maintenance needed
+ manualChunks: (id) => {
+ // Vendor chunks
+ if (id.includes('node_modules')) {
+ // Split large vendor libraries into separate chunks
+ if (id.includes('@mui')) return 'mui';
+ if (id.includes('react-router')) return 'router';
+ if (id.includes('react') || id.includes('react-dom')) return 'react';
+ return 'vendor';
+ }
+
+ // Feature-based chunking - automatically handles new features
+ if (id.includes('/features/')) {
+ const featureName = id.split('/features/')[1]?.split('/')[0];
+ if (featureName) return `feature-${featureName}`;
+ }
+
+ // Page-based chunking - automatically handles new pages
+ if (id.includes('/pages/')) {
+ const pageName = id.split('/pages/')[1]?.split('.')[0];
+ if (pageName) return `page-${pageName}`;
+ }
+
+ // Component-based chunking for large component directories
+ if (id.includes('/components/') && id.includes('/part-discovery/')) {
+ return 'components-part-discovery';
+ }
+ }
+ }
+ },
+
+ // Disable source maps in production for faster builds
+ sourcemap: false
+ },
+
+ // Optimize dev server
+ server: {
+ hmr: {
+ overlay: false
+ }
+ },
+
+ // Optimize dependency pre-bundling
+ optimizeDeps: {
+ include: [
+ 'react',
+ 'react-dom',
+ 'react-router-dom',
+ '@mui/material',
+ '@mui/icons-material'
+ ]
+ },
+
+ // Additional optimizations for Docker builds
+ esbuild: {
+ target: 'esnext',
+ // Drop console logs in production
+ drop: process.env.NODE_ENV === 'production' ? ['console', 'debugger'] : []
+ }
+})