Repository: Grashjs/cmms
Pull request #41: Asset hierarchy mobile
Author: login: iib0011
Created: 2025-05-05T09:56:53Z | Merged: 2025-05-05T09:57:00Z



Diff:
diff --git a/api/src/main/java/com/grash/dto/AssetMiniDTO.java b/api/src/main/java/com/grash/dto/AssetMiniDTO.java
index 18bd93f30..588c8a074 100644
--- a/api/src/main/java/com/grash/dto/AssetMiniDTO.java
+++ b/api/src/main/java/com/grash/dto/AssetMiniDTO.java
@@ -14,4 +14,6 @@ public class AssetMiniDTO {
 
     private String customId;
 
+    private Long parentId;
+
 }
diff --git a/api/src/main/java/com/grash/mapper/AssetMapper.java b/api/src/main/java/com/grash/mapper/AssetMapper.java
index ff4563045..7c7671385 100644
--- a/api/src/main/java/com/grash/mapper/AssetMapper.java
+++ b/api/src/main/java/com/grash/mapper/AssetMapper.java
@@ -17,7 +17,8 @@
 import java.util.Collections;
 import java.util.Date;
 
-@Mapper(componentModel = "spring", uses = {CustomerMapper.class, VendorMapper.class, UserMapper.class, TeamMapper.class, FileMapper.class, PartMapper.class, FileMapper.class})
+@Mapper(componentModel = "spring", uses = {CustomerMapper.class, VendorMapper.class, UserMapper.class,
+        TeamMapper.class, FileMapper.class, PartMapper.class, FileMapper.class})
 public interface AssetMapper {
     Asset updateAsset(@MappingTarget Asset entity, AssetPatchDTO dto);
 
@@ -26,10 +27,12 @@ public interface AssetMapper {
 
     AssetShowDTO toShowDto(Asset model, @Context AssetService assetService);
 
+    @Mapping(target = "parentId", source = "parentAsset.id")
     AssetMiniDTO toMiniDto(Asset model);
 
     @AfterMapping
-    default AssetShowDTO toShowDto(Asset model, @MappingTarget AssetShowDTO target, @Context AssetService assetService) {
+    default AssetShowDTO toShowDto(Asset model, @MappingTarget AssetShowDTO target,
+                                   @Context AssetService assetService) {
         target.setHasChildren(assetService.hasChildren(model.getId()));
         return target;
     }
diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle
index 355d6b8b7..acc9b674f 100644
--- a/mobile/android/app/build.gradle
+++ b/mobile/android/app/build.gradle
@@ -145,8 +145,8 @@ android {
         applicationId 'com.atlas.cmms'
         minSdkVersion rootProject.ext.minSdkVersion
         targetSdkVersion rootProject.ext.targetSdkVersion
-        versionCode 26
-        versionName "1.0.26"
+        versionCode 27
+        versionName "1.0.27"
         buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
 
         if (isNewArchitectureEnabled()) {
diff --git a/mobile/app.config.ts b/mobile/app.config.ts
index 9ab84633e..dd6101bb1 100644
--- a/mobile/app.config.ts
+++ b/mobile/app.config.ts
@@ -7,7 +7,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
   ...config,
   name: 'Atlas CMMS',
   slug: 'atlas-cmms',
-  version: '1.0.26',
+  version: '1.0.27',
   orientation: 'portrait',
   icon: './assets/images/icon.png',
   scheme: 'atlascmms',
diff --git a/mobile/i18n/translations/ar.ts b/mobile/i18n/translations/ar.ts
index f284f1eeb..4872d13b3 100644
--- a/mobile/i18n/translations/ar.ts
+++ b/mobile/i18n/translations/ar.ts
@@ -1391,7 +1391,13 @@ const locale = {
   server_url_saved: 'تم حفظ عنوان URL للخادم بنجاح',
   error_saving_url: 'حدث خطأ أثناء حفظ عنوان URL للخادم',
   server_url_reset: 'إعادة تعيين عنوان URL للخادم إلى الوضع الافتراضي',
-  reset_to_default: 'إعادة التعيين إلى الوضع الافتراضي'
+  reset_to_default: 'إعادة التعيين إلى الوضع الافتراضي',
+  list: 'قائمة',
+  back_to: 'الرجوع إلى',
+  top_level: 'المستوى الأعلى',
+  no_results_found: 'لم يتم العثور على نتائج',
+  no_sub_assets: 'لا توجد أصول فرعية',
+  no_assets_available: 'لا توجد أصول متاحة'
 };
 
 export default locale;
diff --git a/mobile/i18n/translations/de.ts b/mobile/i18n/translations/de.ts
index 7e1766ef7..483889c6d 100644
--- a/mobile/i18n/translations/de.ts
+++ b/mobile/i18n/translations/de.ts
@@ -1525,7 +1525,13 @@ const deJSON = {
   server_url_saved: 'Server-URL wurde erfolgreich gespeichert',
   error_saving_url: 'Fehler beim Speichern der Server-URL',
   server_url_reset: 'Server-URL wurde auf die Standardwerte zurückgesetzt',
-  reset_to_default: 'Auf Standard zurücksetzen'
+  reset_to_default: 'Auf Standard zurücksetzen',
+  list: 'Liste',
+  back_to: 'Zurück zu',
+  top_level: 'Oberste Ebene',
+  no_results_found: 'Keine Ergebnisse gefunden',
+  no_sub_assets: 'Keine Unter-Assets',
+  no_assets_available: 'Keine verfügbaren Assets'
 };
 
 export default deJSON;
diff --git a/mobile/i18n/translations/en.ts b/mobile/i18n/translations/en.ts
index 087709ce7..50f50d281 100644
--- a/mobile/i18n/translations/en.ts
+++ b/mobile/i18n/translations/en.ts
@@ -1448,7 +1448,12 @@ const locale = {
   server_url_saved: 'Server URL successfully saved',
   error_saving_url: 'Error occurred while saving the Server URL',
   server_url_reset: 'Server URL reset to default',
-  reset_to_default: 'Reset to default'
+  reset_to_default: 'Reset to default',
+  list: 'List',
+  back_to: 'Back to',
+  top_level: 'Top Level',
+  no_results_found: 'No results found',
+  no_sub_assets: 'No sub-assets',
+  no_assets_available: 'No assets available'
 };
-
 export default locale;
diff --git a/mobile/i18n/translations/es.ts b/mobile/i18n/translations/es.ts
index 7c21374bc..0e2584818 100644
--- a/mobile/i18n/translations/es.ts
+++ b/mobile/i18n/translations/es.ts
@@ -1479,7 +1479,13 @@ const locale = {
   error_saving_url: 'Se produjo un error al guardar la URL del servidor',
   server_url_reset:
     'La URL del servidor se restableció a los valores predeterminados',
-  reset_to_default: 'Restablecer a valores predeterminados'
+  reset_to_default: 'Restablecer a valores predeterminados',
+  list: 'Lista',
+  back_to: 'Volver a',
+  top_level: 'Nivel superior',
+  no_results_found: 'No se encontraron resultados',
+  no_sub_assets: 'Sin subactivos',
+  no_assets_available: 'No hay activos disponibles'
 };
 
 export default locale;
diff --git a/mobile/i18n/translations/fr.ts b/mobile/i18n/translations/fr.ts
index 12f599f62..a1f77b9e3 100644
--- a/mobile/i18n/translations/fr.ts
+++ b/mobile/i18n/translations/fr.ts
@@ -586,6 +586,7 @@ const frJSON = {
     'Remplissez les champs ci-dessous pour modifier cet équipement',
   confirm_delete_asset: 'Etes-vous sûrs de vouloir supprimer cet équipement ?',
   no_content: 'Pas de contenu',
+  no_assets_available: 'Aucun équipement disponible',
   no_result_criteria: 'Aucun résultat pour vos critères de recherche',
   loading_assets: 'Chargement des équipements enfants de {{name}} #{{id}}',
   //location
@@ -1600,7 +1601,11 @@ const frJSON = {
     "Une erreur est survenue lors de l'enregistrement de l'URL du serveur",
   server_url_reset:
     "L'URL du serveur a été réinitialisée aux valeurs par défaut",
-  reset_to_default: 'Réinitialiser par défaut'
+  reset_to_default: 'Réinitialiser par défaut',
+  list: 'Liste',
+  back_to: 'Retour à',
+  top_level: 'Niveau supérieur',
+  no_results_found: 'Aucun résultat trouvé',
+  no_sub_assets: 'Aucun sous-équipement'
 };
-
 export default frJSON;
diff --git a/mobile/i18n/translations/pl.ts b/mobile/i18n/translations/pl.ts
index dacd3a210..d71a269b7 100644
--- a/mobile/i18n/translations/pl.ts
+++ b/mobile/i18n/translations/pl.ts
@@ -1459,7 +1459,13 @@ const plJSON = {
   server_url_saved: 'Adres URL serwera został pomyślnie zapisany',
   error_saving_url: 'Wystąpił błąd podczas zapisywania adresu URL serwera',
   server_url_reset: 'Adres URL serwera został zresetowany do domyślnego',
-  reset_to_default: 'Przywróć domyślne'
+  reset_to_default: 'Przywróć domyślne',
+  list: 'Lista',
+  back_to: 'Powrót do',
+  top_level: 'Poziom główny',
+  no_results_found: 'Nie znaleziono wyników',
+  no_sub_assets: 'Brak podzasobów',
+  no_assets_available: 'Brak dostępnych zasobów'
 };
 
 export default plJSON;
diff --git a/mobile/i18n/translations/pt_BR.ts b/mobile/i18n/translations/pt_BR.ts
index 2c19aeabc..f674dfccf 100644
--- a/mobile/i18n/translations/pt_BR.ts
+++ b/mobile/i18n/translations/pt_BR.ts
@@ -1496,7 +1496,13 @@ const ptBRJSON = {
   server_url_saved: 'A URL do servidor foi salva com sucesso',
   error_saving_url: 'Ocorreu um erro ao salvar a URL do servidor',
   server_url_reset: 'A URL do servidor foi redefinida para o padrão',
-  reset_to_default: 'Redefinir para o padrão'
+  reset_to_default: 'Redefinir para o padrão',
+  list: 'Lista',
+  back_to: 'Voltar para',
+  top_level: 'Nível superior',
+  no_results_found: 'Nenhum resultado encontrado',
+  no_sub_assets: 'Sem subativos',
+  no_assets_available: 'Nenhum ativo disponível'
 };
 
 export default ptBRJSON;
diff --git a/mobile/i18n/translations/tr.ts b/mobile/i18n/translations/tr.ts
index 7f09c47de..40b45ea48 100644
--- a/mobile/i18n/translations/tr.ts
+++ b/mobile/i18n/translations/tr.ts
@@ -1526,6 +1526,12 @@ const trJSON = {
   EMERGENCY_SHUTDOWN: 'Acil Kapanış',
   select_asset_status: 'Varlık durumunu seçin',
   asset_status: 'Varlık durumu',
-  custom_server: 'Özel Sunucu'
+  custom_server: 'Özel Sunucu',
+  list: 'Liste',
+  back_to: 'Geri dön',
+  top_level: 'En üst seviye',
+  no_results_found: 'Sonuç bulunamadı',
+  no_sub_assets: 'Alt varlık yok',
+  no_assets_available: 'Mevcut varlık yok'
 };
 export default trJSON;
diff --git a/mobile/models/asset.ts b/mobile/models/asset.ts
index 96c508d78..5ecaddc14 100644
--- a/mobile/models/asset.ts
+++ b/mobile/models/asset.ts
@@ -64,4 +64,5 @@ export interface AssetMiniDTO {
   id: number;
   name: string;
   customId: string;
+  parentId: number;
 }
diff --git a/mobile/screens/modals/SelectAssetsModal.tsx b/mobile/screens/modals/SelectAssetsModal.tsx
index c9f321f76..e8f528d4d 100644
--- a/mobile/screens/modals/SelectAssetsModal.tsx
+++ b/mobile/screens/modals/SelectAssetsModal.tsx
@@ -3,78 +3,149 @@ import {
   RefreshControl,
   ScrollView,
   StyleSheet,
-  TouchableOpacity
+  TouchableOpacity,
+  ActivityIndicator
 } from 'react-native';
 import { View } from '../../components/Themed';
 import { RootStackScreenProps } from '../../types';
 import { useTranslation } from 'react-i18next';
 import * as React from 'react';
-import { useEffect, useState } from 'react';
+import { useEffect, useState, useMemo } from 'react';
 import { useDispatch, useSelector } from '../../store';
 import { AssetMiniDTO } from '../../models/asset';
 import { getAssetsMini } from '../../slices/asset';
-import { Checkbox, Divider, Searchbar, Text, useTheme } from 'react-native-paper';
+import {
+  Checkbox,
+  Searchbar,
+  Text,
+  useTheme,
+  SegmentedButtons,
+  IconButton
+} from 'react-native-paper';
+
+// Interface extending AssetMiniDTO to explicitly include derived 'hasChildren'
+interface AssetHierarchyNode extends AssetMiniDTO {
+  hasChildren: boolean;
+}
 
 export default function SelectAssetsModal({
-                                            navigation,
-                                            route
-                                          }: RootStackScreenProps<'SelectAssets'>) {
+  navigation,
+  route
+}: RootStackScreenProps<'SelectAssets'>) {
   const { onChange, selected, multiple, locationId } = route.params;
   const theme = useTheme();
   const { t }: { t: any } = useTranslation();
   const dispatch = useDispatch();
   const { assetsMini, loadingGet } = useSelector((state) => state.assets);
-  const [selectedAssets, setSelectedAssets] = useState<AssetMiniDTO[]>([]);
+  // Derive the hierarchy structure and 'hasChildren' property from the flat list
+  const assetsHierarchy: AssetHierarchyNode[] = useMemo(() => {
+    const assetMap = new Map(assetsMini.map((asset) => [asset.id, asset]));
+    const childrenMap = new Map<number, boolean>();
+    assetsMini.forEach((asset) => {
+      if (asset.parentId !== null && assetMap.has(asset.parentId)) {
+        childrenMap.set(asset.parentId, true);
+      }
+    });
+    return assetsMini.map((asset) => ({
+      ...asset,
+      hasChildren: childrenMap.has(asset.id) || false
+    }));
+  }, [assetsMini]);
   const [selectedIds, setSelectedIds] = useState<number[]>([]);
   const [searchQuery, setSearchQuery] = useState<string>('');
+  const [view, setView] = useState<'list' | 'hierarchy'>('list');
+  const [currentHierarchyLevel, setCurrentHierarchyLevel] = useState<
+    AssetHierarchyNode[]
+  >([]);
+  // currentHierarchyParent holds the node the user navigated into (null for top level)
+  const [currentHierarchyParent, setCurrentHierarchyParent] =
+    useState<AssetHierarchyNode | null>(null);
 
+  // Find an asset by ID in the derived hierarchy list
+  const findAssetById = (id: number): AssetHierarchyNode | undefined => {
+    return assetsHierarchy.find((a) => a.id === id);
+  };
+  // Initialize selected IDs from route params
   useEffect(() => {
-    if (assetsMini.length) {
-      const newSelectedAssets = selectedIds
-        .map((id) => {
-          return assetsMini.find((asset) => asset.id == id);
-        })
-        .filter((asset) => !!asset);
-      setSelectedAssets(newSelectedAssets);
+    if (selected) {
+      // Check if selected prop has value
+      setSelectedIds(selected);
     }
-  }, [selectedIds, assetsMini]);
+  }, [selected]); // Depend only on selected prop
 
+  // Fetch initial asset data
   useEffect(() => {
-    if (!selectedIds.length) setSelectedIds(selected);
-  }, [selected]);
+    dispatch(getAssetsMini(locationId));
+  }, [locationId, dispatch]);
 
+  // Update header button for multiple selection
   useEffect(() => {
-    if (multiple)
+    if (multiple) {
+      const currentlySelectedAssets = selectedIds
+        .map((id) => findAssetById(id))
+        .filter((asset): asset is AssetHierarchyNode => !!asset); // Use AssetHierarchyNode here
+
       navigation.setOptions({
         headerRight: () => (
           <Pressable
-            disabled={!selectedAssets.length}
+            disabled={!currentlySelectedAssets.length}
             onPress={() => {
-              onChange(selectedAssets);
+              // Pass back the selected assets (ensure format matches onChange expectation)
+              onChange(currentlySelectedAssets);
               navigation.goBack();
             }}
+            style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
           >
-            <Text variant='titleMedium'>{t('add')}</Text>
+            <Text
+              variant="titleMedium"
+              style={{ color: theme.colors.primary, marginRight: 10 }}
+            >
+              {t('add')} ({currentlySelectedAssets.length})
+            </Text>
           </Pressable>
         )
       });
-  }, [selectedAssets]);
+    }
+    // Re-run when selectedIds or assetsHierarchy changes (needed if assets load after initial render)
+  }, [
+    selectedIds,
+    multiple,
+    navigation,
+    onChange,
+    assetsHierarchy,
+    theme.colors.primary,
+    t
+  ]);
 
+  // Update hierarchy view when hierarchy data or parent changes
   useEffect(() => {
-    dispatch(getAssetsMini(locationId));
-  }, [locationId]);
+    // This effect runs when view is 'hierarchy' OR when assetsHierarchy/currentHierarchyParent changes
+    // Filter assetsHierarchy to find children of the current parent (or top-level items if parent is null)
+    const children = assetsHierarchy.filter(
+      (asset) => asset.parentId === (currentHierarchyParent?.id ?? null)
+    );
+    setCurrentHierarchyLevel(children);
+  }, [assetsHierarchy, currentHierarchyParent]); // Removed view dependency, logic now depends only on data/parent
+
+  // --- Event Handlers ---
 
   const onSelect = (ids: number[]) => {
-    setSelectedIds(Array.from(new Set([...selectedIds, ...ids])));
+    const newSelectedIds = Array.from(new Set([...selectedIds, ...ids]));
+    setSelectedIds(newSelectedIds);
     if (!multiple) {
-      onChange([assetsMini.find((asset) => asset.id === ids[0])]);
-      navigation.goBack();
+      const selectedAsset = findAssetById(ids[0]);
+      if (selectedAsset) {
+        onChange([selectedAsset]); // Pass the found asset
+        navigation.goBack();
+      }
     }
   };
+
   const onUnSelect = (ids: number[]) => {
     const newSelectedIds = selectedIds.filter((id) => !ids.includes(id));
     setSelectedIds(newSelectedIds);
   };
+
   const toggle = (id: number) => {
     if (selectedIds.includes(id)) {
       onUnSelect([id]);
@@ -83,56 +154,169 @@ export default function SelectAssetsModal({
     }
   };
 
+  const handleSearchChange = (query: string) => {
+    setSearchQuery(query);
+    // If user starts searching, always switch back to list view
+    if (query) {
+      setView('list');
+    }
+  };
+
+  const handleViewChange = (newView: 'list' | 'hierarchy') => {
+    setView(newView);
+    // Clear search and reset hierarchy navigation when switching views
+    setSearchQuery('');
+    if (newView === 'hierarchy') {
+      setCurrentHierarchyParent(null); // Reset to top level
+    }
+  };
+
+  const navigateHierarchyDown = (asset: AssetHierarchyNode) => {
+    setCurrentHierarchyParent(asset);
+  };
+
+  const navigateHierarchyUp = () => {
+    if (!currentHierarchyParent) return;
+    // Find the parent of the current parent using the derived assetsHierarchy list
+    const grandparent = currentHierarchyParent.parentId
+      ? findAssetById(currentHierarchyParent.parentId)
+      : null;
+    setCurrentHierarchyParent(grandparent || null); // Set to null if no grandparent (i.e., moving to top level)
+  };
+
+  // --- Refresh Handler ---
+  const onRefresh = () => {
+    // Always refresh the base data
+    dispatch(getAssetsMini(locationId));
+    // Reset hierarchy navigation if in hierarchy view
+    if (view === 'hierarchy') {
+      setCurrentHierarchyParent(null);
+    }
+  };
+
+  // --- Rendering ---
+
+  // Reusable function to render an item in either list or hierarchy view
+  const renderListItem = (
+    asset: AssetHierarchyNode,
+    isHierarchyView = false
+  ) => (
+    <TouchableOpacity
+      onPress={() => {
+        // Always allow selection by clicking the item body
+        toggle(asset.id);
+      }}
+      key={asset.id}
+      style={styles.itemContainer}
+    >
+      <View style={styles.itemContent}>
+        {multiple && (
+          <Checkbox
+            status={selectedIds.includes(asset.id) ? 'checked' : 'unchecked'}
+            onPress={() => toggle(asset.id)} // Checkbox also toggles selection
+          />
+        )}
+        <Text style={styles.itemText} variant={'titleMedium'}>
+          {asset.name}
+        </Text>
+      </View>
+      {/* Show drill-down icon only in hierarchy view if the item has children */}
+      {isHierarchyView && asset.hasChildren && (
+        <IconButton
+          icon="chevron-right"
+          size={24}
+          onPress={(e) => {
+            e.stopPropagation(); // Prevent triggering the main TouchableOpacity onPress
+            navigateHierarchyDown(asset); // Navigate down when chevron is pressed
+          }}
+        />
+      )}
+    </TouchableOpacity>
+  );
+
+  // Filtered list for the 'list' view based on search query
+  const filteredListAssets = useMemo(() => {
+    if (!searchQuery) {
+      return assetsHierarchy; // Use derived hierarchy list for consistency if no search
+    }
+    return assetsHierarchy.filter((asset) =>
+      asset.name.toLowerCase().includes(searchQuery.toLowerCase().trim())
+    );
+  }, [assetsHierarchy, searchQuery]);
+
   return (
-    <View style={[styles.container, { backgroundColor: theme.colors.background }]}>
+    <View
+      style={[styles.container, { backgroundColor: theme.colors.background }]}
+    >
       <Searchbar
         placeholder={t('search')}
-        onChangeText={setSearchQuery}
+        onChangeText={handleSearchChange}
         value={searchQuery}
-        style={{ backgroundColor: theme.colors.background }}
+        style={{ backgroundColor: theme.colors.background, margin: 5 }}
+      />
+      <SegmentedButtons
+        value={view}
+        onValueChange={(value) =>
+          handleViewChange(value as 'list' | 'hierarchy')
+        }
+        buttons={[
+          { value: 'list', label: t('list') },
+          { value: 'hierarchy', label: t('hierarchy') }
+        ]}
+        style={styles.segmentedButtons}
       />
+
+      {/* Back button for hierarchy navigation */}
+      {view === 'hierarchy' && currentHierarchyParent && (
+        <TouchableOpacity
+          onPress={navigateHierarchyUp}
+          style={styles.backButton}
+        >
+          <IconButton icon="arrow-left" size={20} />
+          {/* Find the name of the parent's parent for the back button label */}
+          <Text variant="titleMedium">
+            {t('back_to')}{' '}
+            {findAssetById(currentHierarchyParent.parentId ?? -1)?.name ??
+              t('top_level')}
+          </Text>
+        </TouchableOpacity>
+      )}
+
       <ScrollView
         refreshControl={
-          <RefreshControl
-            refreshing={loadingGet}
-            onRefresh={() => dispatch(getAssetsMini())}
-          />
+          <RefreshControl refreshing={loadingGet} onRefresh={onRefresh} />
         }
-        style={{
-          flex: 1,
-          backgroundColor: theme.colors.background
-        }}
+        style={styles.scrollView}
       >
-        {assetsMini.filter(mini => mini.name.toLowerCase().includes(searchQuery.toLowerCase().trim())).map((asset) => (
-          <TouchableOpacity
-            onPress={() => {
-              toggle(asset.id);
-            }}
-            key={asset.id}
-            style={{
-              borderRadius: 5,
-              padding: 15,
-              backgroundColor: 'white',
-              display: 'flex',
-              flexDirection: 'row',
-              elevation: 2,
-              alignItems: 'center'
-            }}
-          >
-            {multiple && (
-              <Checkbox
-                status={
-                  selectedIds.includes(asset.id) ? 'checked' : 'unchecked'
-                }
-                onPress={() => {
-                  toggle(asset.id);
-                }}
-              />
-            )}
-            <Text style={{ flexShrink: 1 }} variant={'titleMedium'}>{asset.name}</Text>
-            <Divider />
-          </TouchableOpacity>
-        ))}
+        {/* Conditional rendering based on view state */}
+        {view === 'list' &&
+          (loadingGet && !filteredListAssets.length ? (
+            <ActivityIndicator animating={true} style={{ marginTop: 20 }} />
+          ) : (
+            // Render the filtered list (which might be the full list if no search)
+            filteredListAssets.map((asset) => renderListItem(asset, false)) // Pass false for isHierarchyView
+          ))}
+
+        {view === 'hierarchy' &&
+          (loadingGet && !currentHierarchyLevel.length ? (
+            <ActivityIndicator animating={true} style={{ marginTop: 20 }} />
+          ) : (
+            // Render the current level of the hierarchy
+            currentHierarchyLevel.map((asset) => renderListItem(asset, true)) // Pass true for isHierarchyView
+          ))}
+
+        {view === 'list' &&
+          !loadingGet &&
+          !filteredListAssets.length &&
+          !!searchQuery && (
+            <Text style={styles.noResultsText}>{t('no_results_found')}</Text>
+          )}
+        {view === 'list' &&
+          !loadingGet &&
+          !filteredListAssets.length &&
+          !searchQuery && (
+            <Text style={styles.noResultsText}>{t('no_assets_available')}</Text>
+          )}
       </ScrollView>
     </View>
   );
@@ -141,5 +325,45 @@ export default function SelectAssetsModal({
 const styles = StyleSheet.create({
   container: {
     flex: 1
+  },
+  segmentedButtons: {
+    marginHorizontal: 10,
+    marginBottom: 10
+  },
+  scrollView: {
+    flex: 1
+  },
+  itemContainer: {
+    marginHorizontal: 10,
+    marginVertical: 4,
+    borderRadius: 5,
+    paddingVertical: 10,
+    paddingHorizontal: 15,
+    backgroundColor: 'white',
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    elevation: 2
+  },
+  itemContent: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    flexShrink: 1
+  },
+  itemText: {
+    marginLeft: 10,
+    flexShrink: 1
+  },
+  backButton: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingHorizontal: 10,
+    paddingVertical: 5
+  },
+  noResultsText: {
+    textAlign: 'center',
+    marginTop: 20,
+    fontSize: 16,
+    color: 'grey'
   }
 });


Reviews:
user: login: Alkaly02
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-05T13:15:49Z
user: login: Alkaly02
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-05T13:17:24Z
user: login: Alkaly02
body: This is just a suggestion
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-05T13:18:03Z
user: login: iib0011
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T00:45:25Z
user: login: Alkaly02
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-12T00:48:07Z

Review comments:
diff_hunk: @@ -145,8 +145,8 @@ android {
         applicationId 'com.atlas.cmms'
         minSdkVersion rootProject.ext.minSdkVersion
         targetSdkVersion rootProject.ext.targetSdkVersion
-        versionCode 26
-        versionName "1.0.26"
path: mobile/android/app/build.gradle
user: login: Alkaly02
body: Hi, you can do this automatically.

You have to update your `package.json` `version` property. We have to do it because it will allow us to upgrade the app `versionCode` so that Play Store can suggest users to update our app.

         For example
            ```json
            "version": "1.0"
            ```
            to
            ```json
            "version": "1.1"
            ```
      3. Upgrade the app `versionCode`

         ```sh
         npx react-native-version --never-amend
         ```
         This command line update the `versionCode` property located in `android/app/build.gradle`
created_at: 2025-05-05T13:15:49Z
author_association: NONE
diff_hunk: @@ -145,8 +145,8 @@ android {
         applicationId 'com.atlas.cmms'
         minSdkVersion rootProject.ext.minSdkVersion
         targetSdkVersion rootProject.ext.targetSdkVersion
-        versionCode 26
-        versionName "1.0.26"
path: mobile/android/app/build.gradle
user: login: Alkaly02
body: The benefit is that, it will update android and iOS versions for you
created_at: 2025-05-05T13:17:24Z
author_association: NONE
diff_hunk: @@ -145,8 +145,8 @@ android {
         applicationId 'com.atlas.cmms'
         minSdkVersion rootProject.ext.minSdkVersion
         targetSdkVersion rootProject.ext.targetSdkVersion
-        versionCode 26
-        versionName "1.0.26"
path: mobile/android/app/build.gradle
user: login: iib0011
body: @Alkaly02 Merci. Je viens de voir
created_at: 2025-05-12T00:45:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -145,8 +145,8 @@ android {
         applicationId 'com.atlas.cmms'
         minSdkVersion rootProject.ext.minSdkVersion
         targetSdkVersion rootProject.ext.targetSdkVersion
-        versionCode 26
-        versionName "1.0.26"
path: mobile/android/app/build.gradle
user: login: Alkaly02
body: Yep
created_at: 2025-05-12T00:48:07Z
author_association: NONE

PR comments:
user: login: netlify[bot]
created_at: 2025-05-05T09:57:09Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *jade-gingersnap-11da58* canceled.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | b3d7e7b54d14fde98050389290974e6c7f6736d5 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/jade-gingersnap-11da58/deploys/68188b6764dd250008ebe215 |
author_association: NONE
user: login: Alkaly02
created_at: 2025-05-12T00:47:09Z
body: Yep

Le lun. 12 mai 2025, à 00 h 45, Ibrahima G. Coulibaly <
***@***.***> a écrit :

> ***@***.**** commented on this pull request.
> ------------------------------
>
> In mobile/android/app/build.gradle
> <https://github.com/Grashjs/cmms/pull/41#discussion_r2083673179>:
>
> > @@ -145,8 +145,8 @@ android {
>          applicationId 'com.atlas.cmms'
>          minSdkVersion rootProject.ext.minSdkVersion
>          targetSdkVersion rootProject.ext.targetSdkVersion
> -        versionCode 26
> -        versionName "1.0.26"
>
> @Alkaly02 <https://github.com/Alkaly02> Merci. Je viens de voir
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/Grashjs/cmms/pull/41#discussion_r2083673179>, or
> unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AVODO6ELDUS53XGBYMPPO3D257VLVAVCNFSM6AAAAAB4OGZMH2VHI2DSMVQWIX3LMV43YUDVNRWFEZLROVSXG5CSMV3GSZLXHMZDQMZRGU3TMNRVHE>
> .
> You are receiving this because you were mentioned.Message ID:
> ***@***.***>
>

author_association: NONE