From b2dc2251d8d9287d583dd066ade82dc0d7757637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Thu, 26 Jun 2025 16:58:53 +0300 Subject: [PATCH] MenuManager --- .../Seeds/ListFormsSeeder.cs | 88 +++---- .../Seeds/SeederData.json | 70 +++++- .../Kurs.Platform.Domain/Data/SeedConsts.cs | 8 +- ui/dev-dist/sw.js | 2 +- ui/package-lock.json | 56 +++++ ui/package.json | 3 + ui/src/@types/menu.ts | 31 +++ ui/src/configs/routes.config/routes.config.ts | 6 + ui/src/constants/route.constant.ts | 1 + ui/src/proxy/menus/index.ts | 1 - .../{proxy/menus => services}/menu.service.ts | 4 +- ui/src/store/store.ts | 2 +- ui/src/utils/hooks/useMenuData.ts | 116 +++++++++ ui/src/views/admin/listForm/Wizard.tsx | 2 +- ui/src/views/menu/MenuItemComponent.tsx | 100 ++++++++ ui/src/views/menu/MenuManager.tsx | 160 ++++++++++++ ui/src/views/menu/SortableMenuTree.tsx | 227 ++++++++++++++++++ 17 files changed, 814 insertions(+), 63 deletions(-) create mode 100644 ui/src/@types/menu.ts rename ui/src/{proxy/menus => services}/menu.service.ts (96%) create mode 100644 ui/src/utils/hooks/useMenuData.ts create mode 100644 ui/src/views/menu/MenuItemComponent.tsx create mode 100644 ui/src/views/menu/MenuManager.tsx create mode 100644 ui/src/views/menu/SortableMenuTree.tsx diff --git a/api/src/Kurs.Platform.DbMigrator/Seeds/ListFormsSeeder.cs b/api/src/Kurs.Platform.DbMigrator/Seeds/ListFormsSeeder.cs index 7312f38a..40ac2e88 100644 --- a/api/src/Kurs.Platform.DbMigrator/Seeds/ListFormsSeeder.cs +++ b/api/src/Kurs.Platform.DbMigrator/Seeds/ListFormsSeeder.cs @@ -2468,13 +2468,13 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency { CultureName = LanguageCodes.En, ListFormCode = ListFormCodes.Lists.Menu, - Name = AppCodes.Menus, - Title = AppCodes.Menus, + Name = AppCodes.Menus.Menu, + Title = AppCodes.Menus.Menu, DataSourceCode = SeedConsts.DataSources.DefaultCode, IsTenant = false, IsBranch = false, IsOrganizationUnit = false, - Description = AppCodes.Menus, + Description = AppCodes.Menus.Menu, SelectCommandType = SelectCommandTypeEnum.Table, SelectCommand = SelectCommandByTableName("Menu"), KeyFieldName = "Id", @@ -2509,11 +2509,11 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency }), PermissionJson = JsonSerializer.Serialize(new PermissionCrudDto { - C = AppCodes.Menus + ".Create", - R = AppCodes.Menus, - U = AppCodes.Menus + ".Update", - D = AppCodes.Menus + ".Delete", - E = AppCodes.Menus + ".Export" + C = AppCodes.Menus.Menu + ".Create", + R = AppCodes.Menus.Menu, + U = AppCodes.Menus.Menu + ".Update", + D = AppCodes.Menus.Menu + ".Delete", + E = AppCodes.Menus.Menu + ".Export" }), DeleteCommand = $"UPDATE \"{DbTablePrefix}Menu\" SET \"DeleterId\"=@DeleterId, \"DeletionTime\"=CURRENT_TIMESTAMP, \"IsDeleted\"='true' WHERE \"Id\"=@Id", DeleteFieldsDefaultValueJson = JsonSerializer.Serialize(new FieldsDefaultValue[] { @@ -2615,9 +2615,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency IsDeleted = false, PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto { - C = AppCodes.Menus + ".Create", - R = AppCodes.Menus, - U = AppCodes.Menus + ".Update", + C = AppCodes.Menus.Menu + ".Create", + R = AppCodes.Menus.Menu, + U = AppCodes.Menus.Menu + ".Update", E = true, Deny = false }), @@ -2647,9 +2647,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency }), PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto { - C = AppCodes.Menus + ".Create", - R = AppCodes.Menus, - U = AppCodes.Menus + ".Update", + C = AppCodes.Menus.Menu + ".Create", + R = AppCodes.Menus.Menu, + U = AppCodes.Menus.Menu + ".Update", E = true, Deny = false }), @@ -2684,9 +2684,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency }), PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto { - C = AppCodes.Menus + ".Create", - R = AppCodes.Menus, - U = AppCodes.Menus + ".Update", + C = AppCodes.Menus.Menu + ".Create", + R = AppCodes.Menus.Menu, + U = AppCodes.Menus.Menu + ".Update", E = true, Deny = false }), @@ -2713,9 +2713,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency }), PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto { - C = AppCodes.Menus + ".Create", - R = AppCodes.Menus, - U = AppCodes.Menus + ".Update", + C = AppCodes.Menus.Menu + ".Create", + R = AppCodes.Menus.Menu, + U = AppCodes.Menus.Menu + ".Update", E = true, Deny = false }), @@ -2740,9 +2740,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency AllowSearch = true, PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto { - C = AppCodes.Menus + ".Create", - R = AppCodes.Menus, - U = AppCodes.Menus + ".Update", + C = AppCodes.Menus.Menu + ".Create", + R = AppCodes.Menus.Menu, + U = AppCodes.Menus.Menu + ".Update", E = true, Deny = false }), @@ -2767,9 +2767,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency AllowSearch = true, PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto { - C = AppCodes.Menus + ".Create", - R = AppCodes.Menus, - U = AppCodes.Menus + ".Update", + C = AppCodes.Menus.Menu + ".Create", + R = AppCodes.Menus.Menu, + U = AppCodes.Menus.Menu + ".Update", E = true, Deny = false }), @@ -2801,9 +2801,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency }), PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto { - C = AppCodes.Menus + ".Create", - R = AppCodes.Menus, - U = AppCodes.Menus + ".Update", + C = AppCodes.Menus.Menu + ".Create", + R = AppCodes.Menus.Menu, + U = AppCodes.Menus.Menu + ".Update", E = true, Deny = false }), @@ -2828,9 +2828,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency AllowSearch = true, PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto { - C = AppCodes.Menus + ".Create", - R = AppCodes.Menus, - U = AppCodes.Menus + ".Update", + C = AppCodes.Menus.Menu + ".Create", + R = AppCodes.Menus.Menu, + U = AppCodes.Menus.Menu + ".Update", E = true, Deny = false }), @@ -2862,9 +2862,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency }), PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto { - C = AppCodes.Menus + ".Create", - R = AppCodes.Menus, - U = AppCodes.Menus + ".Update", + C = AppCodes.Menus.Menu + ".Create", + R = AppCodes.Menus.Menu, + U = AppCodes.Menus.Menu + ".Update", E = true, Deny = false }), @@ -2889,9 +2889,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency AllowSearch = true, PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto { - C = AppCodes.Menus + ".Create", - R = AppCodes.Menus, - U = AppCodes.Menus + ".Update", + C = AppCodes.Menus.Menu + ".Create", + R = AppCodes.Menus.Menu, + U = AppCodes.Menus.Menu + ".Update", E = true, Deny = false }), @@ -2915,9 +2915,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency IsDeleted = false, PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto { - C = AppCodes.Menus + ".Create", - R = AppCodes.Menus, - U = AppCodes.Menus + ".Update", + C = AppCodes.Menus.Menu + ".Create", + R = AppCodes.Menus.Menu, + U = AppCodes.Menus.Menu + ".Update", E = true, Deny = false }), @@ -2942,9 +2942,9 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency AllowSearch = true, PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto { - C = AppCodes.Menus + ".Create", - R = AppCodes.Menus, - U = AppCodes.Menus + ".Update", + C = AppCodes.Menus.Menu + ".Create", + R = AppCodes.Menus.Menu, + U = AppCodes.Menus.Menu + ".Update", E = true, Deny = false }), diff --git a/api/src/Kurs.Platform.DbMigrator/Seeds/SeederData.json b/api/src/Kurs.Platform.DbMigrator/Seeds/SeederData.json index 950f6077..56e4a9ab 100644 --- a/api/src/Kurs.Platform.DbMigrator/Seeds/SeederData.json +++ b/api/src/Kurs.Platform.DbMigrator/Seeds/SeederData.json @@ -606,6 +606,12 @@ "en": "Update", "tr": "Değiştir" }, + { + "resourceName": "Platform", + "key": "Manager", + "en": "Manager", + "tr": "Yönetici" + }, { "resourceName": "Platform", "key": "Cancel", @@ -960,6 +966,18 @@ "en": "Menu Management", "tr": "Menü Yönetimi" }, + { + "resourceName": "Platform", + "key": "App.Menus.Menu", + "en": "Menu List", + "tr": "Menü Listesi" + }, + { + "resourceName": "Platform", + "key": "App.Menus.Manager", + "en": "Menu Manager", + "tr": "Menü Yöneticisi" + }, { "resourceName": "Platform", "key": "App.Setting", @@ -6285,9 +6303,29 @@ "Code": "App.Menus", "DisplayName": "App.Menus", "Order": 5, + "Url": null, + "Icon": "FaSchlix", + "RequiredPermissionName": null, + "IsDisabled": false + }, + { + "ParentCode": "App.Menus", + "Code": "App.Menus.Menu", + "DisplayName": "App.Menus.Menu", + "Order": 1, "Url": "/list/list-menu", "Icon": "FcMenu", - "RequiredPermissionName": "App.Menus", + "RequiredPermissionName": "App.Menus.Menu", + "IsDisabled": false + }, + { + "ParentCode": "App.Menus", + "Code": "App.Menus.Manager", + "DisplayName": "App.Menus.Manager", + "Order": 2, + "Url": "/menumanager", + "Icon": "FaRegListAlt", + "RequiredPermissionName": "App.Menus.Manager", "IsDisabled": false }, { @@ -6392,7 +6430,7 @@ }, { "ParentCode": "App.Saas", - "Code": "AApp.BlogManagement", + "Code": "App.BlogManagement", "DisplayName": "App.BlogManagement", "Order": 10, "Url": "/admin/blogmanagement", @@ -6857,9 +6895,9 @@ }, { "GroupName": "App.Menus", - "Name": "App.Menus", + "Name": "App.Menus.Menu", "ParentName": null, - "DisplayName": "App.Menus", + "DisplayName": "App.Menus.Menu", "IsEnabled": true, "MultiTenancySide": 2 }, @@ -7609,36 +7647,44 @@ }, { "GroupName": "App.Menus", - "Name": "App.Menus.Create", - "ParentName": "App.Menus", + "Name": "App.Menus.Menu.Create", + "ParentName": "App.Menus.Menu", "DisplayName": "Create", "IsEnabled": true, "MultiTenancySide": 2 }, { "GroupName": "App.Menus", - "Name": "App.Menus.Delete", - "ParentName": "App.Menus", + "Name": "App.Menus.Menu.Delete", + "ParentName": "App.Menus.Menu", "DisplayName": "Delete", "IsEnabled": true, "MultiTenancySide": 2 }, { "GroupName": "App.Menus", - "Name": "App.Menus.Export", - "ParentName": "App.Menus", + "Name": "App.Menus.Menu.Export", + "ParentName": "App.Menus.Menu", "DisplayName": "Export", "IsEnabled": true, "MultiTenancySide": 2 }, { "GroupName": "App.Menus", - "Name": "App.Menus.Update", - "ParentName": "App.Menus", + "Name": "App.Menus.Menu.Update", + "ParentName": "App.Menus.Menu", "DisplayName": "Update", "IsEnabled": true, "MultiTenancySide": 2 }, + { + "GroupName": "App.Menus", + "Name": "App.Menus.Manager", + "ParentName": null, + "DisplayName": "App.Menus.Manager", + "IsEnabled": true, + "MultiTenancySide": 2 + }, { "GroupName": "App.Notifications.Notification", "Name": "App.Notifications.Notification.Create", diff --git a/api/src/Kurs.Platform.Domain/Data/SeedConsts.cs b/api/src/Kurs.Platform.Domain/Data/SeedConsts.cs index cf6a2fbd..8fc87ce9 100644 --- a/api/src/Kurs.Platform.Domain/Data/SeedConsts.cs +++ b/api/src/Kurs.Platform.Domain/Data/SeedConsts.cs @@ -323,7 +323,13 @@ public static class SeedConsts public const string Language = Default + ".Language"; public const string LanguageText = Default + ".LanguageText"; } - public const string Menus = Prefix.App + ".Menus"; + public static class Menus + { + public const string Default = Prefix.App + ".Menus"; + + public const string Menu = Default + ".Menu"; + public const string Manager = Default + ".Manager"; + } public static class Listforms { public const string Default = Prefix.App + ".Listforms"; diff --git a/ui/dev-dist/sw.js b/ui/dev-dist/sw.js index e51093f3..06dd6165 100644 --- a/ui/dev-dist/sw.js +++ b/ui/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-54d0af47'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.e088p05bgmo" + "revision": "0.scg6n9r90k8" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/ui/package-lock.json b/ui/package-lock.json index 5d86d055..84192a3d 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,6 +8,9 @@ "name": "kurs-platform-ui", "version": "1.0.4", "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@floating-ui/react": "^0.27.2", "@fullcalendar/daygrid": "^6.1.8", "@fullcalendar/interaction": "^6.1.8", @@ -1653,6 +1656,59 @@ "inferno-hydrate": "^7.4.6" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", diff --git a/ui/package.json b/ui/package.json index bae34605..1a811a7a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,6 +15,9 @@ "format": "npm run prettier:fix && npm run lint:fix" }, "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@floating-ui/react": "^0.27.2", "@fullcalendar/daygrid": "^6.1.8", "@fullcalendar/interaction": "^6.1.8", diff --git a/ui/src/@types/menu.ts b/ui/src/@types/menu.ts new file mode 100644 index 00000000..d4a5c9c1 --- /dev/null +++ b/ui/src/@types/menu.ts @@ -0,0 +1,31 @@ +import { MenuDto } from "@/proxy/menus"; + +export interface MenuItem extends MenuDto { + children?: MenuItem[]; +} + +export interface MenuApiResponse { + totalCount: number; + items: MenuItem[]; +} + +export interface DragEndEvent { + active: { + id: string; + data: { + current: { + type: string; + item: MenuItem; + }; + }; + }; + over: { + id: string; + data: { + current: { + type: string; + item?: MenuItem; + }; + }; + } | null; +} \ No newline at end of file diff --git a/ui/src/configs/routes.config/routes.config.ts b/ui/src/configs/routes.config/routes.config.ts index 9f4fead0..71c3b659 100644 --- a/ui/src/configs/routes.config/routes.config.ts +++ b/ui/src/configs/routes.config/routes.config.ts @@ -75,4 +75,10 @@ export const protectedRoutes: Routes = [ component: lazy(() => import('@/views/docs/ChangeLog')), authority: [], }, + { + key: ROUTES_ENUM.menumanager, + path: ROUTES_ENUM.menumanager, + component: lazy(() => import('@/views/menu/MenuManager')), + authority: [], + }, ] diff --git a/ui/src/constants/route.constant.ts b/ui/src/constants/route.constant.ts index 41c30947..e28e54ef 100644 --- a/ui/src/constants/route.constant.ts +++ b/ui/src/constants/route.constant.ts @@ -53,4 +53,5 @@ export const ROUTES_ENUM = { docs: { changelog: '/docs/changelog' }, + menumanager: '/menumanager', } diff --git a/ui/src/proxy/menus/index.ts b/ui/src/proxy/menus/index.ts index eca61acc..ad200c53 100644 --- a/ui/src/proxy/menus/index.ts +++ b/ui/src/proxy/menus/index.ts @@ -1,2 +1 @@ -export * from './menu.service' export * from './models' diff --git a/ui/src/proxy/menus/menu.service.ts b/ui/src/services/menu.service.ts similarity index 96% rename from ui/src/proxy/menus/menu.service.ts rename to ui/src/services/menu.service.ts index 1d5e310a..4af2afec 100644 --- a/ui/src/proxy/menus/menu.service.ts +++ b/ui/src/services/menu.service.ts @@ -1,6 +1,6 @@ +import { PagedAndSortedResultRequestDto, PagedResultDto } from '@/proxy/abp' +import { MenuDto } from '@/proxy/menus/models' import apiService, { Config } from '@/services/api.service' -import type { MenuDto } from './models' -import { PagedAndSortedResultRequestDto, PagedResultDto } from '../abp' export class MenuService { apiName = 'Default' diff --git a/ui/src/store/store.ts b/ui/src/store/store.ts index a91094ce..63ec4e0e 100644 --- a/ui/src/store/store.ts +++ b/ui/src/store/store.ts @@ -1,5 +1,4 @@ import { refreshToken } from '@/proxy/account/auth.service' -import { MenuService } from '@/proxy/menus' import { createStore, createTypedHooks, persist } from 'easy-peasy' import { Config as ReduxStateSyncConfig, @@ -14,6 +13,7 @@ import { AuthModel, authModel } from './auth.model' import { BaseModel, baseModel } from './base.model' import { LocaleModel, localeModel } from './locale.model' import { ThemeModel, themeModel } from './theme.model' +import { MenuService } from '@/services/menu.service' export interface StoreModel { abpConfig: AbpConfigModel diff --git a/ui/src/utils/hooks/useMenuData.ts b/ui/src/utils/hooks/useMenuData.ts new file mode 100644 index 00000000..c1dfea71 --- /dev/null +++ b/ui/src/utils/hooks/useMenuData.ts @@ -0,0 +1,116 @@ +import { MenuApiResponse, MenuItem } from '@/@types/menu' +import { getMenus } from '@/services/menu.service' +import { useState, useEffect } from 'react' + +export const useMenuData = () => { + const [menuItems, setMenuItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const buildHierarchy = (items: MenuItem[]): MenuItem[] => { + const itemMap = new Map() + const rootItems: MenuItem[] = [] + + // Create a map for quick lookup and initialize children arrays + items.forEach((item) => { + itemMap.set(item.code!!, { ...item, children: [] }) + }) + + // Build the hierarchy + items.forEach((item) => { + const menuItem = itemMap.get(item.code!!)! + + if (item.parentCode && itemMap.has(item.parentCode)) { + const parent = itemMap.get(item.parentCode)! + if (!parent.children) { + parent.children = [] + } + parent.children.push(menuItem) + } else { + rootItems.push(menuItem) + } + }) + + // Sort items by order recursively + const sortItems = (items: MenuItem[]): MenuItem[] => { + return items + .sort((a, b) => a.order - b.order) + .map((item) => ({ + ...item, + children: item.children && item.children.length > 0 ? sortItems(item.children) : [], + })) + } + + return sortItems(rootItems) + } + + const fetchMenuData = async () => { + try { + setLoading(true) + setError(null) + + // Simulate API call with mock data + await new Promise((resolve) => setTimeout(resolve, 1000)) + + const response = await getMenus() + + if (response.data) { + const hierarchicalMenu = buildHierarchy(response.data.items) + setMenuItems(hierarchicalMenu) + } + + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load menu data') + } finally { + setLoading(false) + } + } + + const saveMenuData = async (updatedMenuItems: MenuItem[]) => { + try { + // Flatten the hierarchy for API + const flatten = (items: MenuItem[], parentCode: string | null = null): MenuItem[] => { + const result: MenuItem[] = [] + items.forEach((item, index) => { + const flatItem = { + ...item, + parentCode, + order: index + 1, + children: undefined, + } + result.push(flatItem) + + if (item.children && item.children.length > 0) { + result.push(...flatten(item.children, item.code)) + } + }) + return result + } + + const flatMenuItems = flatten(updatedMenuItems) + + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 1000)) + + console.log('Saving menu data:', flatMenuItems) + // In real implementation: await api.saveMenus(flatMenuItems); + + return { success: true } + } catch (err) { + throw new Error(err instanceof Error ? err.message : 'Failed to save menu data') + } + } + + useEffect(() => { + fetchMenuData() + }, []) + + return { + menuItems, + setMenuItems, + loading, + error, + refetch: fetchMenuData, + saveMenuData, + } +} diff --git a/ui/src/views/admin/listForm/Wizard.tsx b/ui/src/views/admin/listForm/Wizard.tsx index 8b7ddd9f..d03a6bba 100644 --- a/ui/src/views/admin/listForm/Wizard.tsx +++ b/ui/src/views/admin/listForm/Wizard.tsx @@ -14,7 +14,6 @@ import { postListFormWizard } from '@/proxy/admin/list-form/list-form.service' import { ListFormWizardDto } from '@/proxy/admin/list-form/models' import { getDataSources } from '@/proxy/data-source' import { DbTypeEnum, SelectCommandTypeEnum } from '@/proxy/form' -import { getMenus } from '@/proxy/menus' import { SelectBoxOption } from '@/shared/types' import { useLocalization } from '@/utils/hooks/useLocalization' import { Field, FieldProps, Form, Formik } from 'formik' @@ -24,6 +23,7 @@ import { useNavigate } from 'react-router-dom' import CreatableSelect from 'react-select/creatable' import * as Yup from 'yup' import { dbSourceTypeOptions, selectCommandTypeOptions } from './edit/options' +import { getMenus } from '@/services/menu.service' const initialValues: ListFormWizardDto = { listFormCode: '', diff --git a/ui/src/views/menu/MenuItemComponent.tsx b/ui/src/views/menu/MenuItemComponent.tsx new file mode 100644 index 00000000..932b1817 --- /dev/null +++ b/ui/src/views/menu/MenuItemComponent.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import * as Icons from 'lucide-react' +import { MenuItem } from '@/@types/menu' +import { useLocalization } from '@/utils/hooks/useLocalization' + +interface MenuItemComponentProps { + item: MenuItem + isDesignMode: boolean + depth: number + children?: React.ReactNode + isDragOverlay?: boolean +} + +export const MenuItemComponent: React.FC = ({ + item, + isDesignMode, + depth, + children, + isDragOverlay = false, +}) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: item.id || '', + data: { + type: 'menu-item', + item, + }, + disabled: !isDesignMode, + }) + + const { translate } = useLocalization() + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + + const getIcon = (iconName: string) => { + const IconComponent = (Icons as any)[iconName] + return IconComponent ? : + } + + const [isExpanded, setIsExpanded] = React.useState(true) + + const toggleExpanded = () => { + if (!isDesignMode) { + setIsExpanded(!isExpanded) + } + } + + return ( +
+
0 ? 'bg-blue-50' : depth === 0 ? 'bg-white' : 'bg-gray-50'} + `} + {...(isDesignMode ? { ...attributes, ...listeners } : { onClick: toggleExpanded })} + > +
+
{item.icon && getIcon(item.icon)}
+ + 0 ? 'font-semibold' : 'font-normal'} + `} + > + {translate('::' + item.displayName)} + + + {item.url && } +
+ +
+ {isDesignMode && ( +
+ #{item.order} +
+ )} + + {item.children && item.children.length > 0 && ( + + {item.children.length} + + )} +
+
+ + {children && (!isDesignMode ? isExpanded : true) &&
{children}
} +
+ ) +} diff --git a/ui/src/views/menu/MenuManager.tsx b/ui/src/views/menu/MenuManager.tsx new file mode 100644 index 00000000..1240661e --- /dev/null +++ b/ui/src/views/menu/MenuManager.tsx @@ -0,0 +1,160 @@ +import React, { useState } from 'react' +import * as Icons from 'lucide-react' +import { SortableMenuTree } from './SortableMenuTree' +import { MenuItem } from '@/@types/menu' +import { useMenuData } from '@/utils/hooks/useMenuData' + +export const MenuManager = () => { + const { menuItems, setMenuItems, loading, error, refetch, saveMenuData } = useMenuData() + const [isDesignMode, setIsDesignMode] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [saveMessage, setSaveMessage] = useState<{ + type: 'success' | 'error' + text: string + } | null>(null) + + const handleMenuChange = (updatedItems: MenuItem[]) => { + setMenuItems(updatedItems) + } + + const handleSave = async () => { + if (!isDesignMode) return + + try { + setIsSaving(true) + setSaveMessage(null) + + await saveMenuData(menuItems) + + setSaveMessage({ type: 'success', text: 'Menu configuration saved successfully!' }) + setTimeout(() => setSaveMessage(null), 3000) + } catch (err) { + setSaveMessage({ + type: 'error', + text: err instanceof Error ? err.message : 'Failed to save menu configuration', + }) + setTimeout(() => setSaveMessage(null), 5000) + } finally { + setIsSaving(false) + } + } + + const handleToggleDesignMode = () => { + setIsDesignMode(!isDesignMode) + setSaveMessage(null) + } + + if (loading) { + return ( +
+
+ + Loading menu configuration... +
+
+ ) + } + + if (error) { + return ( +
+
+
+ +

Error Loading Menu

+
+

{error}

+ +
+
+ ) + } + + return ( +
+
+ {/* Menu Tree */} +
+
+ {/* Sol kısım: Başlık */} +
+ +

Menu Manager

+ ({menuItems.length} root items) +
+ + {/* Sağ kısım: Design Mode + Save butonu */} +
+
+ + Design Mode + + +
+ + { + + } +
+
+ + {menuItems.length > 0 ? ( + + ) : ( +
+ +

No menu items found

+

Try refreshing the page or contact your administrator

+
+ )} +
+
+
+ ) +} + +export default MenuManager diff --git a/ui/src/views/menu/SortableMenuTree.tsx b/ui/src/views/menu/SortableMenuTree.tsx new file mode 100644 index 00000000..5d6f3c68 --- /dev/null +++ b/ui/src/views/menu/SortableMenuTree.tsx @@ -0,0 +1,227 @@ +import React from 'react' +import { + DndContext, + DragOverlay, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragStartEvent, + DragEndEvent, +} from '@dnd-kit/core' +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { MenuItemComponent } from './MenuItemComponent' +import { MenuItem } from '@/@types/menu' + +interface SortableMenuTreeProps { + items: MenuItem[] + onItemsChange: (items: MenuItem[]) => void + isDesignMode: boolean +} + +export const SortableMenuTree: React.FC = ({ + items, + onItemsChange, + isDesignMode, +}) => { + const [activeItem, setActiveItem] = React.useState(null) + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ) + + // Flatten the tree structure to get all items with their paths + const flattenItems = ( + items: MenuItem[], + parentPath: string[] = [], + ): Array<{ item: MenuItem; path: number[] }> => { + const result: Array<{ item: MenuItem; path: number[] }> = [] + + items.forEach((item, index) => { + const currentPath = [...parentPath, index] + result.push({ item, path: currentPath }) + + if (item.children && item.children.length > 0) { + result.push(...flattenItems(item.children, currentPath)) + } + }) + + return result + } + + // Find item by ID in the tree + const findItemById = (items: MenuItem[], id: string): MenuItem | null => { + for (const item of items) { + if (item.id === id) { + return item + } + if (item.children) { + const found = findItemById(item.children, id) + if (found) return found + } + } + return null + } + + // Remove item from tree by ID + const removeItemFromTree = (items: MenuItem[], id: string): MenuItem[] => { + return items.reduce((acc: MenuItem[], item) => { + if (item.id === id) { + return acc // Skip this item (remove it) + } + + const newItem = { ...item } + if (newItem.children && newItem.children.length > 0) { + newItem.children = removeItemFromTree(newItem.children, id) + } + + acc.push(newItem) + return acc + }, []) + } + + // Insert item at specific position + const insertItemAtPath = ( + items: MenuItem[], + item: MenuItem, + targetPath: number[], + ): MenuItem[] => { + if (targetPath.length === 1) { + const newItems = [...items] + newItems.splice(targetPath[0], 0, item) + return newItems + } + + const [firstIndex, ...restPath] = targetPath + const newItems = [...items] + + if (newItems[firstIndex]) { + newItems[firstIndex] = { + ...newItems[firstIndex], + children: insertItemAtPath(newItems[firstIndex].children || [], item, restPath), + } + } + + return newItems + } + + // Get the path where an item should be inserted based on over item + const getInsertionPath = ( + items: MenuItem[], + activeId: string, + overId: string, + ): number[] | null => { + const flatItems = flattenItems(items) + const activeIndex = flatItems.findIndex(({ item }) => item.id === activeId) + const overIndex = flatItems.findIndex(({ item }) => item.id === overId) + + if (overIndex === -1) return null + + const overItem = flatItems[overIndex] + const insertPath = [...overItem.path] + + // Aktif item, listedeki over item'den sonra geliyorsa, yukarı taşınıyordur → over item'ın yerine ekle + // Aktif item, listedeki over item'den önceyse, aşağı taşınıyordur → bir SONRASINA ekle ki yeniden aynı yere düşmesin + if (activeIndex < overIndex) { + insertPath[insertPath.length - 1] += 1 + } + + return insertPath + } + + const updateOrderNumbers = (items: MenuItem[]): MenuItem[] => { + return items.map((item, index) => ({ + ...item, + order: index + 1, + children: item.children ? updateOrderNumbers(item.children) : [], + })) + } + + const handleDragStart = (event: DragStartEvent) => { + const { active } = event + const activeItem = findItemById(items, active.id as string) + setActiveItem(activeItem) + } + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + setActiveItem(null) + + if (!over || active.id === over.id || !isDesignMode) { + return + } + + const activeId = active.id as string + const overId = over.id as string + + const activeItem = findItemById(items, activeId) + if (!activeItem) return + + // ⛳️ Kullanılması gereken liste: `items` + const insertionPath = getInsertionPath(items, activeId, overId) + if (!insertionPath) return + + // Şimdi aktif elemanı çıkar + let newItems = removeItemFromTree(items, activeId) + + // ve hedef konuma ekle + newItems = insertItemAtPath(newItems, activeItem, insertionPath) + + // Sıra numaralarını güncelle + const finalItems = updateOrderNumbers(newItems) + onItemsChange(finalItems) + } + + const renderMenuItem = (item: MenuItem, depth: number = 0): React.ReactNode => { + return ( + + {item.children && item.children.length > 0 && ( +
+ {item.children.map((child) => renderMenuItem(child, depth + 1))} +
+ )} +
+ ) + } + + const allItems = flattenItems(items) + + return ( + + item.id)} + strategy={verticalListSortingStrategy} + > +
{items.map((item) => renderMenuItem(item))}
+
+ + + {activeItem ? ( + + ) : null} + +
+ ) +}