Commit 0afa2a004dd9672b95b29278e271bb6fe4752327

Authored by “wangming”
2 parents 499e4a42 1bdbd1fc

Merge branch 'main' of http://39.98.150.180/wangming/Food-Labeling-Management-Platform

Showing 29 changed files with 4233 additions and 751 deletions
.cursor/mcp.json
... ... @@ -5,7 +5,7 @@
5 5 "args": [
6 6 "--yes",
7 7 "@davewind/mysql-mcp-server",
8   - "mysql://netteam:netteam@rm-bp19ohrgc6111ynzh1o.mysql.rds.aliyuncs.com:3306/antis-foodlabeling-us"
  8 + "mysql://javateam:javateam2026@rm-bp19ohrgc6111ynzh1o.mysql.rds.aliyuncs.com:3306/antis-foodlabeling-us"
9 9 ]
10 10 },
11 11 "my-api-spec": {
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/RbacMenu/RbacMenuCreateInputVo.cs
... ... @@ -7,7 +7,10 @@ public class RbacMenuCreateInputVo
7 7 {
8 8 public string MenuName { get; set; } = string.Empty;
9 9  
10   - public Guid ParentId { get; set; }
  10 + /// <summary>
  11 + /// 父级ID(menu 表为字符串ID,可能是数字;根节点默认 0)
  12 + /// </summary>
  13 + public string ParentId { get; set; } = "0";
11 14  
12 15 public int MenuType { get; set; }
13 16  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/RbacMenu/RbacMenuGetListOutputDto.cs
... ... @@ -5,9 +5,9 @@ namespace FoodLabeling.Application.Contracts.Dtos.RbacMenu;
5 5 /// </summary>
6 6 public class RbacMenuGetListOutputDto
7 7 {
8   - public Guid Id { get; set; }
  8 + public string Id { get; set; } = string.Empty;
9 9  
10   - public Guid ParentId { get; set; }
  10 + public string ParentId { get; set; } = string.Empty;
11 11  
12 12 public string MenuName { get; set; } = string.Empty;
13 13  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/RbacMenu/RbacMenuTreeDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.RbacMenu;
  2 +
  3 +/// <summary>
  4 +/// 权限树节点(返回菜单表全部字段)
  5 +/// </summary>
  6 +public class RbacMenuTreeDto
  7 +{
  8 + public string Id { get; set; } = string.Empty;
  9 +
  10 + public bool IsDeleted { get; set; }
  11 +
  12 + public DateTime CreationTime { get; set; }
  13 +
  14 + public string? CreatorId { get; set; }
  15 +
  16 + public string? LastModifierId { get; set; }
  17 +
  18 + public DateTime? LastModificationTime { get; set; }
  19 +
  20 + public int OrderNum { get; set; }
  21 +
  22 + public bool State { get; set; }
  23 +
  24 + public string MenuName { get; set; } = string.Empty;
  25 +
  26 + public string? RouterName { get; set; }
  27 +
  28 + public int MenuType { get; set; }
  29 +
  30 + public string? PermissionCode { get; set; }
  31 +
  32 + public string ParentId { get; set; } = string.Empty;
  33 +
  34 + public string? MenuIcon { get; set; }
  35 +
  36 + public string? Router { get; set; }
  37 +
  38 + public bool IsLink { get; set; }
  39 +
  40 + public bool IsCache { get; set; }
  41 +
  42 + public bool IsShow { get; set; }
  43 +
  44 + public string? Remark { get; set; }
  45 +
  46 + public string? Component { get; set; }
  47 +
  48 + public int MenuSource { get; set; }
  49 +
  50 + public string? Query { get; set; }
  51 +
  52 + public string? ConcurrencyStamp { get; set; }
  53 +
  54 + public List<RbacMenuTreeDto>? Children { get; set; }
  55 +}
  56 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IRbacMenuAppService.cs
1 1 using FoodLabeling.Application.Contracts.Dtos.RbacMenu;
2   -using FoodLabeling.Application.Contracts.Dtos.Common;
3   -using Volo.Abp.Application.Dtos;
4 2 using Volo.Abp.Application.Services;
5 3  
6 4 namespace FoodLabeling.Application.Contracts.IServices;
... ... @@ -11,14 +9,14 @@ namespace FoodLabeling.Application.Contracts.IServices;
11 9 public interface IRbacMenuAppService : IApplicationService
12 10 {
13 11 /// <summary>
14   - /// 权限分页列表
  12 + /// 权限列表(不分页)
15 13 /// </summary>
16   - Task<PagedResultWithPageDto<RbacMenuGetListOutputDto>> GetListAsync(RbacMenuGetListInputVo input);
  14 + Task<List<RbacMenuGetListOutputDto>> GetListAsync(RbacMenuGetListInputVo input);
17 15  
18 16 /// <summary>
19 17 /// 权限详情
20 18 /// </summary>
21   - Task<RbacMenuGetListOutputDto> GetAsync(Guid id);
  19 + Task<RbacMenuGetListOutputDto> GetAsync(string id);
22 20  
23 21 /// <summary>
24 22 /// 新增权限
... ... @@ -28,11 +26,17 @@ public interface IRbacMenuAppService : IApplicationService
28 26 /// <summary>
29 27 /// 编辑权限
30 28 /// </summary>
31   - Task<RbacMenuGetListOutputDto> UpdateAsync(Guid id, RbacMenuUpdateInputVo input);
  29 + Task<RbacMenuGetListOutputDto> UpdateAsync(string id, RbacMenuUpdateInputVo input);
32 30  
33 31 /// <summary>
34 32 /// 删除权限(逻辑删除)
35 33 /// </summary>
36   - Task DeleteAsync(List<Guid> ids);
  34 + Task DeleteAsync(List<string> ids);
  35 +
  36 + /// <summary>
  37 + /// 获取全部权限树(GET)
  38 + /// </summary>
  39 + /// <returns>树状权限列表</returns>
  40 + Task<List<RbacMenuTreeDto>> GetTreeAsync();
37 41 }
38 42  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/MenuDbEntity.cs 0 → 100644
  1 +using SqlSugar;
  2 +
  3 +namespace FoodLabeling.Application.Services.DbModels;
  4 +
  5 +/// <summary>
  6 +/// menu 表映射(兼容数字/字符串类型的 Id、ParentId)
  7 +/// </summary>
  8 +[SugarTable("menu")]
  9 +public class MenuDbEntity
  10 +{
  11 + [SugarColumn(IsPrimaryKey = true)]
  12 + public string Id { get; set; } = string.Empty;
  13 +
  14 + public bool IsDeleted { get; set; }
  15 +
  16 + public DateTime CreationTime { get; set; }
  17 +
  18 + public string? CreatorId { get; set; }
  19 +
  20 + public string? LastModifierId { get; set; }
  21 +
  22 + public DateTime? LastModificationTime { get; set; }
  23 +
  24 + public int OrderNum { get; set; }
  25 +
  26 + public bool State { get; set; }
  27 +
  28 + public string MenuName { get; set; } = string.Empty;
  29 +
  30 + public string? RouterName { get; set; }
  31 +
  32 + public int MenuType { get; set; }
  33 +
  34 + public string? PermissionCode { get; set; }
  35 +
  36 + public string ParentId { get; set; } = "0";
  37 +
  38 + public string? MenuIcon { get; set; }
  39 +
  40 + public string? Router { get; set; }
  41 +
  42 + public bool IsLink { get; set; }
  43 +
  44 + public bool IsCache { get; set; }
  45 +
  46 + public bool IsShow { get; set; }
  47 +
  48 + public string? Remark { get; set; }
  49 +
  50 + public string? Component { get; set; }
  51 +
  52 + public int MenuSource { get; set; }
  53 +
  54 + public string? Query { get; set; }
  55 +
  56 + public string? ConcurrencyStamp { get; set; }
  57 +}
  58 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/RbacMenuAppService.cs
1 1 using FoodLabeling.Application.Contracts.Dtos.RbacMenu;
2   -using FoodLabeling.Application.Contracts.Dtos.Common;
3 2 using FoodLabeling.Application.Contracts.IServices;
  3 +using FoodLabeling.Application.Services.DbModels;
4 4 using Microsoft.AspNetCore.Mvc;
5 5 using SqlSugar;
6 6 using Volo.Abp;
7 7 using Volo.Abp.Application.Services;
8   -using Volo.Abp.Domain.Entities;
9   -using Yi.Framework.Rbac.Domain.Entities;
10 8 using Yi.Framework.SqlSugarCore.Abstractions;
11 9  
12 10 namespace FoodLabeling.Application.Services;
... ... @@ -16,57 +14,44 @@ namespace FoodLabeling.Application.Services;
16 14 /// </summary>
17 15 public class RbacMenuAppService : ApplicationService, IRbacMenuAppService
18 16 {
19   - private readonly ISqlSugarRepository<MenuAggregateRoot, Guid> _menuRepository;
  17 + private readonly ISqlSugarDbContext _dbContext;
20 18  
21   - public RbacMenuAppService(ISqlSugarRepository<MenuAggregateRoot, Guid> menuRepository)
  19 + public RbacMenuAppService(ISqlSugarDbContext dbContext)
22 20 {
23   - _menuRepository = menuRepository;
  21 + _dbContext = dbContext;
24 22 }
25 23  
26 24 /// <inheritdoc />
27   - public async Task<PagedResultWithPageDto<RbacMenuGetListOutputDto>> GetListAsync([FromQuery] RbacMenuGetListInputVo input)
  25 + public async Task<List<RbacMenuGetListOutputDto>> GetListAsync([FromQuery] RbacMenuGetListInputVo input)
28 26 {
29   - RefAsync<int> total = 0;
30   -
31   - var query = _menuRepository._DbQueryable
  27 + var query = _dbContext.SqlSugarClient.Queryable<MenuDbEntity>()
32 28 .Where(x => x.IsDeleted == false)
33 29 .WhereIF(!string.IsNullOrWhiteSpace(input.MenuName), x => x.MenuName.Contains(input.MenuName!.Trim()))
34 30 .WhereIF(input.State is not null, x => x.State == input.State)
35   - .WhereIF(input.MenuSource is not null, x => x.MenuSource == (Yi.Framework.Rbac.Domain.Shared.Enums.MenuSourceEnum)input.MenuSource!.Value)
  31 + .WhereIF(input.MenuSource is not null, x => x.MenuSource == input.MenuSource!.Value)
36 32 .OrderBy(x => x.OrderNum, OrderByType.Desc);
37 33  
38   - var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
  34 + var entities = await query.ToListAsync();
39 35  
40   - var items = entities.Select(x => new RbacMenuGetListOutputDto
  36 + return entities.Select(x => new RbacMenuGetListOutputDto
41 37 {
42 38 Id = x.Id,
43 39 ParentId = x.ParentId,
44 40 MenuName = x.MenuName ?? string.Empty,
45 41 PermissionCode = x.PermissionCode,
46   - MenuType = (int)x.MenuType,
47   - MenuSource = (int)x.MenuSource,
  42 + MenuType = x.MenuType,
  43 + MenuSource = x.MenuSource,
48 44 OrderNum = x.OrderNum,
49 45 State = x.State
50 46 }).ToList();
51   -
52   - var pageSize = input.MaxResultCount <= 0 ? items.Count : input.MaxResultCount;
53   - var pageIndex = pageSize <= 0 ? 1 : (input.SkipCount / pageSize) + 1;
54   - var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
55   -
56   - return new PagedResultWithPageDto<RbacMenuGetListOutputDto>
57   - {
58   - PageIndex = pageIndex,
59   - PageSize = pageSize,
60   - TotalCount = total,
61   - TotalPages = totalPages,
62   - Items = items
63   - };
64 47 }
65 48  
66 49 /// <inheritdoc />
67   - public async Task<RbacMenuGetListOutputDto> GetAsync(Guid id)
  50 + public async Task<RbacMenuGetListOutputDto> GetAsync(string id)
68 51 {
69   - var entity = await _menuRepository.GetSingleAsync(x => x.Id == id && x.IsDeleted == false);
  52 + var entity = await _dbContext.SqlSugarClient.Queryable<MenuDbEntity>()
  53 + .Where(x => x.Id == id && x.IsDeleted == false)
  54 + .SingleAsync();
70 55 if (entity is null)
71 56 {
72 57 throw new UserFriendlyException("权限不存在");
... ... @@ -78,8 +63,8 @@ public class RbacMenuAppService : ApplicationService, IRbacMenuAppService
78 63 ParentId = entity.ParentId,
79 64 MenuName = entity.MenuName ?? string.Empty,
80 65 PermissionCode = entity.PermissionCode,
81   - MenuType = (int)entity.MenuType,
82   - MenuSource = (int)entity.MenuSource,
  66 + MenuType = entity.MenuType,
  67 + MenuSource = entity.MenuSource,
83 68 OrderNum = entity.OrderNum,
84 69 State = entity.State
85 70 };
... ... @@ -94,28 +79,35 @@ public class RbacMenuAppService : ApplicationService, IRbacMenuAppService
94 79 throw new UserFriendlyException("权限名称不能为空");
95 80 }
96 81  
97   - var entity = new MenuAggregateRoot
  82 + var entity = new MenuDbEntity
98 83 {
  84 + Id = GuidGenerator.Create().ToString(),
99 85 MenuName = name,
100   - ParentId = input.ParentId,
101   - MenuType = (Yi.Framework.Rbac.Domain.Shared.Enums.MenuTypeEnum)input.MenuType,
102   - MenuSource = (Yi.Framework.Rbac.Domain.Shared.Enums.MenuSourceEnum)input.MenuSource,
  86 + ParentId = string.IsNullOrWhiteSpace(input.ParentId) ? "0" : input.ParentId.Trim(),
  87 + MenuType = input.MenuType,
  88 + MenuSource = input.MenuSource,
103 89 PermissionCode = input.PermissionCode?.Trim(),
104 90 Router = input.Router?.Trim(),
105 91 Component = input.Component?.Trim(),
106 92 OrderNum = input.OrderNum,
107   - State = input.State
  93 + State = input.State,
  94 + IsDeleted = false,
  95 + CreationTime = DateTime.Now,
  96 + IsCache = false,
  97 + IsLink = false,
  98 + IsShow = true,
  99 + ConcurrencyStamp = string.Empty
108 100 };
109   - EntityHelper.TrySetId(entity, () => GuidGenerator.Create());
110   -
111   - await _menuRepository.InsertAsync(entity);
  101 + await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync();
112 102 return await GetAsync(entity.Id);
113 103 }
114 104  
115 105 /// <inheritdoc />
116   - public async Task<RbacMenuGetListOutputDto> UpdateAsync(Guid id, [FromBody] RbacMenuUpdateInputVo input)
  106 + public async Task<RbacMenuGetListOutputDto> UpdateAsync(string id, [FromBody] RbacMenuUpdateInputVo input)
117 107 {
118   - var entity = await _menuRepository.GetSingleAsync(x => x.Id == id && x.IsDeleted == false);
  108 + var entity = await _dbContext.SqlSugarClient.Queryable<MenuDbEntity>()
  109 + .Where(x => x.Id == id && x.IsDeleted == false)
  110 + .SingleAsync();
119 111 if (entity is null)
120 112 {
121 113 throw new UserFriendlyException("权限不存在");
... ... @@ -128,30 +120,118 @@ public class RbacMenuAppService : ApplicationService, IRbacMenuAppService
128 120 }
129 121  
130 122 entity.MenuName = name;
131   - entity.ParentId = input.ParentId;
132   - entity.MenuType = (Yi.Framework.Rbac.Domain.Shared.Enums.MenuTypeEnum)input.MenuType;
133   - entity.MenuSource = (Yi.Framework.Rbac.Domain.Shared.Enums.MenuSourceEnum)input.MenuSource;
  123 + entity.ParentId = string.IsNullOrWhiteSpace(input.ParentId) ? "0" : input.ParentId.Trim();
  124 + entity.MenuType = input.MenuType;
  125 + entity.MenuSource = input.MenuSource;
134 126 entity.PermissionCode = input.PermissionCode?.Trim();
135 127 entity.Router = input.Router?.Trim();
136 128 entity.Component = input.Component?.Trim();
137 129 entity.OrderNum = input.OrderNum;
138 130 entity.State = input.State;
  131 + entity.LastModificationTime = DateTime.Now;
139 132  
140   - await _menuRepository.UpdateAsync(entity);
  133 + await _dbContext.SqlSugarClient.Updateable(entity)
  134 + .Where(x => x.Id == entity.Id)
  135 + .ExecuteCommandAsync();
141 136 return await GetAsync(entity.Id);
142 137 }
143 138  
144 139 /// <inheritdoc />
145   - public async Task DeleteAsync([FromBody] List<Guid> ids)
  140 + public async Task DeleteAsync([FromBody] List<string> ids)
146 141 {
147   - var idList = ids?.Distinct().ToList() ?? new List<Guid>();
  142 + var idList = ids?.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct().ToList() ?? new List<string>();
148 143 if (idList.Count == 0)
149 144 {
150 145 return;
151 146 }
152 147  
153   - // 权限表为软删(ISoftDelete)
154   - await _menuRepository.DeleteAsync(x => idList.Contains(x.Id));
  148 + await _dbContext.SqlSugarClient.Updateable<MenuDbEntity>()
  149 + .SetColumns(x => new MenuDbEntity { IsDeleted = true })
  150 + .Where(x => idList.Contains(x.Id))
  151 + .ExecuteCommandAsync();
  152 + }
  153 +
  154 + /// <inheritdoc />
  155 + public async Task<List<RbacMenuTreeDto>> GetTreeAsync()
  156 + {
  157 + // 返回所有字段,但过滤逻辑删除数据
  158 + var menus = await _dbContext.SqlSugarClient.Queryable<MenuDbEntity>()
  159 + .Where(x => x.IsDeleted == false)
  160 + .OrderBy(x => x.OrderNum, OrderByType.Desc)
  161 + .ToListAsync();
  162 +
  163 + var nodes = menus.Select(m => new RbacMenuTreeDto
  164 + {
  165 + Id = m.Id,
  166 + IsDeleted = m.IsDeleted,
  167 + CreationTime = m.CreationTime,
  168 + CreatorId = m.CreatorId,
  169 + LastModifierId = m.LastModifierId,
  170 + LastModificationTime = m.LastModificationTime,
  171 + OrderNum = m.OrderNum,
  172 + State = m.State,
  173 + MenuName = m.MenuName ?? string.Empty,
  174 + RouterName = m.RouterName,
  175 + MenuType = m.MenuType,
  176 + PermissionCode = m.PermissionCode,
  177 + ParentId = m.ParentId,
  178 + MenuIcon = m.MenuIcon,
  179 + Router = m.Router,
  180 + IsLink = m.IsLink,
  181 + IsCache = m.IsCache,
  182 + IsShow = m.IsShow,
  183 + Remark = m.Remark,
  184 + Component = m.Component,
  185 + MenuSource = m.MenuSource,
  186 + Query = m.Query,
  187 + ConcurrencyStamp = m.ConcurrencyStamp,
  188 + Children = new List<RbacMenuTreeDto>()
  189 + }).ToList();
  190 +
  191 + // TreeHelper 仅支持 Guid Id/ParentId,这里使用字符串 ParentId 自行构建树
  192 + var nodeById = nodes
  193 + .Where(x => !string.IsNullOrWhiteSpace(x.Id))
  194 + .GroupBy(x => x.Id)
  195 + .ToDictionary(g => g.Key, g => g.First());
  196 +
  197 + foreach (var node in nodes)
  198 + {
  199 + node.Children ??= new List<RbacMenuTreeDto>();
  200 + var parentId = string.IsNullOrWhiteSpace(node.ParentId) ? "0" : node.ParentId.Trim();
  201 + if (parentId == "0" || parentId == "00000000-0000-0000-0000-000000000000")
  202 + {
  203 + continue;
  204 + }
  205 +
  206 + if (nodeById.TryGetValue(parentId, out var parent))
  207 + {
  208 + parent.Children ??= new List<RbacMenuTreeDto>();
  209 + parent.Children.Add(node);
  210 + }
  211 + }
  212 +
  213 + var roots = nodes
  214 + .Where(n =>
  215 + {
  216 + var pid = string.IsNullOrWhiteSpace(n.ParentId) ? "0" : n.ParentId.Trim();
  217 + return pid == "0" || pid == "00000000-0000-0000-0000-000000000000" || !nodeById.ContainsKey(pid);
  218 + })
  219 + .ToList();
  220 +
  221 + SortTree(roots);
  222 + return roots;
  223 + }
  224 +
  225 + private static void SortTree(List<RbacMenuTreeDto> nodes)
  226 + {
  227 + nodes.Sort((a, b) => b.OrderNum.CompareTo(a.OrderNum));
  228 + foreach (var node in nodes)
  229 + {
  230 + if (node.Children is { Count: > 0 })
  231 + {
  232 + SortTree(node.Children);
  233 + }
  234 + }
155 235 }
156 236 }
157 237  
... ...
美国版/Food Labeling Management Platform/.env.local 0 → 100644
  1 +VITE_API_BASE_URL=http://192.168.31.88:19001
  2 +
... ...
美国版/Food Labeling Management Platform/build/assets/index-2Xatwc8-.js 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/index-ChMXTsCq.js deleted
No preview for this file type
美国版/Food Labeling Management Platform/build/index.html
1   -
2   - <!DOCTYPE html>
3   - <html lang="en">
4   - <head>
5   - <meta charset="UTF-8" />
6   - <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7   - <title>Food Labeling Management Platform</title>
8   - <script type="module" crossorigin src="/assets/index-ChMXTsCq.js"></script>
  1 +
  2 + <!DOCTYPE html>
  3 + <html lang="en">
  4 + <head>
  5 + <meta charset="UTF-8" />
  6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  7 + <title>Food Labeling Management Platform</title>
  8 + <script type="module" crossorigin src="/assets/index-2Xatwc8-.js"></script>
9 9 <link rel="stylesheet" crossorigin href="/assets/index-DKXCW1Pt.css">
10   - </head>
11   -
12   - <body>
13   - <div id="root"></div>
14   - </body>
15   - </html>
  10 + </head>
  11 +
  12 + <body>
  13 + <div id="root"></div>
  14 + </body>
  15 + </html>
16 16  
17 17 \ No newline at end of file
... ...
美国版/Food Labeling Management Platform/src/App.tsx
... ... @@ -10,6 +10,7 @@ import { ProductsView } from &#39;./components/products/ProductsView&#39;;
10 10 import { PeopleView } from './components/people/PeopleView';
11 11 import { ReportsView } from './components/reports/ReportsView';
12 12 import { LocationsView } from './components/locations/LocationsView';
  13 +import { SystemMenuView } from './components/system-menu/SystemMenuView';
13 14  
14 15 export default function App() {
15 16 const [currentView, setCurrentView] = useState('Dashboard');
... ... @@ -22,8 +23,11 @@ export default function App() {
22 23 return <TrainingView />;
23 24 case 'Alerts':
24 25 return <AlertsView />;
25   - case 'Menu Manager':
  26 + case 'Menu Management':
  27 + // 还原:Menu Management 对应“商品管理/新增”页面
26 28 return <ProductsView />;
  29 + case 'System Menu':
  30 + return <SystemMenuView />;
27 31 case 'Account Management':
28 32 return <PeopleView />;
29 33 case 'Reports':
... ...
美国版/Food Labeling Management Platform/src/components/layout/Sidebar.tsx
... ... @@ -46,8 +46,10 @@ export function Sidebar({ currentView, setCurrentView }: SidebarProps) {
46 46 },
47 47 { type: 'header', name: 'MANAGEMENT' },
48 48 { name: 'Location Manager', icon: MapPin, type: 'item' },
  49 + { type: 'header', name: 'SYSTEM MANAGEMENT' },
49 50 { name: 'Account Management', icon: Users, type: 'item' },
50   - { name: 'Menu Manager', icon: Package, type: 'item' },
  51 + { name: 'Menu Management', icon: Package, type: 'item' },
  52 + { name: 'System Menu', icon: Settings, type: 'item' },
51 53 { name: 'Reports', icon: FileText, type: 'item' },
52 54 { name: 'Support', icon: HelpCircle, type: 'item' },
53 55 { name: 'Log Out', icon: LogOut, type: 'item' },
... ...
美国版/Food Labeling Management Platform/src/components/locations/LocationsView.tsx
1   -import React, { useState } from 'react';
2   -import {
3   - Search,
4   - Plus,
5   - Download,
6   - Upload,
7   - Edit,
8   - MapPin,
9   - Phone,
10   - Mail,
11   - MoreHorizontal
12   -} from 'lucide-react';
  1 +import React, { useEffect, useMemo, useRef, useState } from "react";
  2 +import { Edit, MapPin, MoreHorizontal } from "lucide-react";
13 3 import { Button } from "../ui/button";
14 4 import { Input } from "../ui/input";
15 5 import {
... ... @@ -38,67 +28,152 @@ import {
38 28 import { Label } from "../ui/label";
39 29 import { Badge } from "../ui/badge";
40 30 import { Switch } from "../ui/switch";
  31 +import { toast } from "sonner";
  32 +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
  33 +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
  34 +import {
  35 + Pagination,
  36 + PaginationContent,
  37 + PaginationItem,
  38 + PaginationLink,
  39 + PaginationNext,
  40 + PaginationPrevious,
  41 +} from "../ui/pagination";
  42 +import { createLocation, deleteLocation, getLocations, updateLocation } from "../../services/locationService";
  43 +import type { LocationCreateInput, LocationDto } from "../../types/location";
41 44  
42   -// --- Mock Data ---
43   -
44   -const MOCK_LOCATIONS = [
45   - {
46   - id: '12345',
47   - name: 'Downtown Store',
48   - street: '123 Main St',
49   - city: 'New York',
50   - state: 'NY',
51   - country: 'USA',
52   - zipCode: '10001',
53   - phone: '+1 (555) 123-4567',
54   - email: 'downtown@example.com',
55   - gps: '40.7128° N, 74.0060° W',
56   - status: 'active'
57   - },
58   - {
59   - id: '12335',
60   - name: 'Uptown Market',
61   - street: '456 High St',
62   - city: 'New York',
63   - state: 'NY',
64   - country: 'USA',
65   - zipCode: '10002',
66   - phone: '+1 (555) 987-6543',
67   - email: 'uptown@example.com',
68   - gps: '40.7580° N, 73.9855° W',
69   - status: 'active'
70   - },
71   - {
72   - id: '12445',
73   - name: 'Airport Kiosk',
74   - street: 'Terminal 4, JFK Airport',
75   - city: 'Jamaica',
76   - state: 'NY',
77   - country: 'USA',
78   - zipCode: '11430',
79   - phone: '+1 (555) 555-5555',
80   - email: 'jfk@example.com',
81   - gps: '40.6413° N, 73.7781° W',
82   - status: 'active'
83   - },
84   - {
85   - id: '12555',
86   - name: 'Suburban Outlet',
87   - street: '789 Country Rd',
88   - city: 'Long Island',
89   - state: 'NY',
90   - country: 'USA',
91   - zipCode: '11901',
92   - phone: '+1 (555) 111-2222',
93   - email: 'suburb@example.com',
94   - gps: '40.8500° N, 73.2000° W',
95   - status: 'inactive'
96   - },
97   -];
  45 +function toDisplay(v: string | null | undefined): string {
  46 + const s = (v ?? "").trim();
  47 + return s ? s : "N/A";
  48 +}
  49 +
  50 +function formatGps(lat: number | null | undefined, lng: number | null | undefined): string {
  51 + if (lat === null || lat === undefined || lng === null || lng === undefined) return "N/A";
  52 + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return "N/A";
  53 + return `${lat}, ${lng}`;
  54 +}
98 55  
99 56 export function LocationsView() {
100 57 const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
101   - const [locations, setLocations] = useState(MOCK_LOCATIONS);
  58 + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
  59 + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
  60 + const [editingLocation, setEditingLocation] = useState<LocationDto | null>(null);
  61 + const [deletingLocation, setDeletingLocation] = useState<LocationDto | null>(null);
  62 + const [locations, setLocations] = useState<LocationDto[]>([]);
  63 + const [loading, setLoading] = useState(false);
  64 + const [total, setTotal] = useState(0);
  65 + const [refreshSeq, setRefreshSeq] = useState(0);
  66 + const [actionsOpenForId, setActionsOpenForId] = useState<string | null>(null);
  67 +
  68 + const [keyword, setKeyword] = useState("");
  69 + const [partner, setPartner] = useState<string>("all");
  70 + const [groupName, setGroupName] = useState<string>("all");
  71 + const [locationPick, setLocationPick] = useState<string>("all");
  72 +
  73 + const [pageIndex, setPageIndex] = useState(1);
  74 + const [pageSize, setPageSize] = useState(10);
  75 +
  76 + const abortRef = useRef<AbortController | null>(null);
  77 + const keywordTimerRef = useRef<number | null>(null);
  78 + const [debouncedKeyword, setDebouncedKeyword] = useState("");
  79 +
  80 + useEffect(() => {
  81 + if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current);
  82 + keywordTimerRef.current = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300);
  83 + return () => {
  84 + if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current);
  85 + };
  86 + }, [keyword]);
  87 +
  88 + // Options derived from current result set (no dedicated endpoints provided in doc).
  89 + const partnerOptions = useMemo(() => {
  90 + const s = new Set<string>();
  91 + for (const x of locations) {
  92 + const v = (x.partner ?? "").trim();
  93 + if (v) s.add(v);
  94 + }
  95 + return ["all", ...Array.from(s).sort((a, b) => a.localeCompare(b))];
  96 + }, [locations]);
  97 +
  98 + const groupOptions = useMemo(() => {
  99 + const s = new Set<string>();
  100 + for (const x of locations) {
  101 + const v = (x.groupName ?? "").trim();
  102 + if (v) s.add(v);
  103 + }
  104 + return ["all", ...Array.from(s).sort((a, b) => a.localeCompare(b))];
  105 + }, [locations]);
  106 +
  107 + const locationOptions = useMemo(() => {
  108 + const s = new Set<string>();
  109 + for (const x of locations) {
  110 + const v = (x.locationCode ?? "").trim();
  111 + if (v) s.add(v);
  112 + }
  113 + return ["all", ...Array.from(s).sort((a, b) => a.localeCompare(b))];
  114 + }, [locations]);
  115 +
  116 + const totalPages = Math.max(1, Math.ceil(total / pageSize));
  117 +
  118 + useEffect(() => {
  119 + // When filter changes, reset to first page.
  120 + setPageIndex(1);
  121 + // eslint-disable-next-line react-hooks/exhaustive-deps
  122 + }, [debouncedKeyword, partner, groupName, locationPick, pageSize]);
  123 +
  124 + useEffect(() => {
  125 + const run = async () => {
  126 + abortRef.current?.abort();
  127 + const ac = new AbortController();
  128 + abortRef.current = ac;
  129 +
  130 + setLoading(true);
  131 + try {
  132 + // 统一约定:SkipCount 传“页码(从1开始)”
  133 + const skipCount = Math.max(1, pageIndex);
  134 + const effectiveKeyword = locationPick !== "all" ? locationPick : debouncedKeyword;
  135 + const res = await getLocations(
  136 + {
  137 + skipCount,
  138 + maxResultCount: pageSize,
  139 + keyword: effectiveKeyword || undefined,
  140 + partner: partner !== "all" ? partner : undefined,
  141 + groupName: groupName !== "all" ? groupName : undefined,
  142 + },
  143 + ac.signal,
  144 + );
  145 +
  146 + setLocations(res.items ?? []);
  147 + setTotal(res.totalCount ?? 0);
  148 + } catch (e: any) {
  149 + if (e?.name === "AbortError") return;
  150 + toast.error("Failed to load locations.", {
  151 + description: e?.message ? String(e.message) : "Please try again.",
  152 + });
  153 + setLocations([]);
  154 + setTotal(0);
  155 + } finally {
  156 + setLoading(false);
  157 + }
  158 + };
  159 +
  160 + run();
  161 + return () => abortRef.current?.abort();
  162 + }, [debouncedKeyword, partner, groupName, locationPick, pageIndex, pageSize, refreshSeq]);
  163 +
  164 + const refreshList = () => setRefreshSeq((x) => x + 1);
  165 +
  166 + const openEdit = (loc: LocationDto) => {
  167 + setActionsOpenForId(null);
  168 + setEditingLocation(loc);
  169 + setIsEditDialogOpen(true);
  170 + };
  171 +
  172 + const openDelete = (loc: LocationDto) => {
  173 + setActionsOpenForId(null);
  174 + setDeletingLocation(loc);
  175 + setIsDeleteDialogOpen(true);
  176 + };
102 177  
103 178 return (
104 179 <div className="h-full flex flex-col">
... ... @@ -109,49 +184,83 @@ export function LocationsView() {
109 184 <div className="flex flex-nowrap items-center gap-3">
110 185 <Input
111 186 placeholder="Search"
  187 + value={keyword}
  188 + onChange={(e) => setKeyword(e.target.value)}
112 189 style={{ height: 40, boxSizing: 'border-box' }}
113 190 className="border border-gray-300 rounded-md w-40 shrink-0 bg-white placeholder:text-gray-500"
114 191 />
115   - <Select defaultValue="partner-a">
  192 + <Select value={partner} onValueChange={setPartner}>
116 193 <SelectTrigger className="w-[140px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0" style={{ height: 40, boxSizing: 'border-box' }}>
117 194 <SelectValue placeholder="Partner" />
118 195 </SelectTrigger>
119 196 <SelectContent>
120   - <SelectItem value="partner-a">Partner A</SelectItem>
  197 + {partnerOptions.map((p) => (
  198 + <SelectItem key={p} value={p}>
  199 + {p === "all" ? "Partner (All)" : p}
  200 + </SelectItem>
  201 + ))}
121 202 </SelectContent>
122 203 </Select>
123   - <Select defaultValue="group-b">
  204 + <Select value={groupName} onValueChange={setGroupName}>
124 205 <SelectTrigger className="w-[140px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0" style={{ height: 40, boxSizing: 'border-box' }}>
125 206 <SelectValue placeholder="Group" />
126 207 </SelectTrigger>
127 208 <SelectContent>
128   - <SelectItem value="group-b">Group B</SelectItem>
  209 + {groupOptions.map((g) => (
  210 + <SelectItem key={g} value={g}>
  211 + {g === "all" ? "Group (All)" : g}
  212 + </SelectItem>
  213 + ))}
129 214 </SelectContent>
130 215 </Select>
131   - <Select defaultValue="all">
  216 + <Select value={locationPick} onValueChange={setLocationPick}>
132 217 <SelectTrigger className="w-[140px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0" style={{ height: 40, boxSizing: 'border-box' }}>
133 218 <SelectValue placeholder="Location" />
134 219 </SelectTrigger>
135 220 <SelectContent>
136   - <SelectItem value="all">All Locations</SelectItem>
137   - <SelectItem value="loc-1">Location 1</SelectItem>
  221 + {locationOptions.map((x) => (
  222 + <SelectItem key={x} value={x}>
  223 + {x === "all" ? "All Locations" : x}
  224 + </SelectItem>
  225 + ))}
138 226 </SelectContent>
139 227 </Select>
140 228 <div className="flex-1" />
141   - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0">
142   - Bulk Import
143   - </Button>
144   - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0">
145   - Bulk Export
146   - </Button>
147   - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0">
148   - Bulk Edit
149   - </Button>
  229 + <Tooltip>
  230 + <TooltipTrigger asChild>
  231 + <span>
  232 + <Button disabled variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0">
  233 + Bulk Import
  234 + </Button>
  235 + </span>
  236 + </TooltipTrigger>
  237 + <TooltipContent>Not supported yet</TooltipContent>
  238 + </Tooltip>
  239 + <Tooltip>
  240 + <TooltipTrigger asChild>
  241 + <span>
  242 + <Button disabled variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0">
  243 + Bulk Export
  244 + </Button>
  245 + </span>
  246 + </TooltipTrigger>
  247 + <TooltipContent>Not supported yet</TooltipContent>
  248 + </Tooltip>
  249 + <Tooltip>
  250 + <TooltipTrigger asChild>
  251 + <span>
  252 + <Button disabled variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 px-4 bg-white hover:bg-gray-50 shrink-0">
  253 + Bulk Edit
  254 + </Button>
  255 + </span>
  256 + </TooltipTrigger>
  257 + <TooltipContent>Not supported yet</TooltipContent>
  258 + </Tooltip>
150 259 <Button
151 260 className="h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-md px-6 font-medium shrink-0"
152 261 onClick={() => setIsCreateDialogOpen(true)}
153 262 >
154   - New+
  263 + New
155 264 </Button>
156 265 </div>
157 266 </div>
... ... @@ -163,6 +272,8 @@ export function LocationsView() {
163 272 <Table>
164 273 <TableHeader>
165 274 <TableRow className="bg-gray-100 hover:bg-gray-100">
  275 + <TableHead className="text-gray-900 font-bold border-r">Partner</TableHead>
  276 + <TableHead className="text-gray-900 font-bold border-r">Group</TableHead>
166 277 <TableHead className="text-gray-900 font-bold border-r">Location ID</TableHead>
167 278 <TableHead className="text-gray-900 font-bold border-r">Location Name</TableHead>
168 279 <TableHead className="text-gray-900 font-bold border-r">Street</TableHead>
... ... @@ -173,42 +284,283 @@ export function LocationsView() {
173 284 <TableHead className="text-gray-900 font-bold border-r">Phone</TableHead>
174 285 <TableHead className="text-gray-900 font-bold border-r">Email</TableHead>
175 286 <TableHead className="text-gray-900 font-bold border-r">GPS</TableHead>
  287 + <TableHead className="text-gray-900 font-bold border-r">Active</TableHead>
176 288 <TableHead className="text-gray-900 font-bold text-center">Actions</TableHead>
177 289 </TableRow>
178 290 </TableHeader>
179 291 <TableBody>
180   - {locations.map((loc) => (
181   - <TableRow key={loc.id}>
182   - <TableCell className="border-r font-numeric text-gray-600">{loc.id}</TableCell>
183   - <TableCell className="border-r font-medium text-black">{loc.name}</TableCell>
184   - <TableCell className="border-r text-gray-600 max-w-[140px] truncate">{loc.street}</TableCell>
185   - <TableCell className="border-r text-gray-600">{loc.city}</TableCell>
186   - <TableCell className="border-r text-gray-600">{loc.state}</TableCell>
187   - <TableCell className="border-r text-gray-600">{loc.country}</TableCell>
188   - <TableCell className="border-r text-gray-600 font-numeric">{loc.zipCode}</TableCell>
189   - <TableCell className="border-r text-gray-600 whitespace-nowrap">{loc.phone}</TableCell>
190   - <TableCell className="border-r text-gray-600 text-sm">{loc.email}</TableCell>
191   - <TableCell className="border-r text-gray-500 font-numeric text-xs">{loc.gps}</TableCell>
192   - <TableCell className="text-center">
193   - <Button variant="ghost" size="icon" className="h-8 w-8">
194   - <MoreHorizontal className="h-4 w-4 text-gray-500" />
195   - </Button>
  292 + {loading ? (
  293 + <TableRow>
  294 + <TableCell colSpan={14} className="text-center text-sm text-gray-500 py-10">
  295 + Loading...
  296 + </TableCell>
  297 + </TableRow>
  298 + ) : locations.length === 0 ? (
  299 + <TableRow>
  300 + <TableCell colSpan={14} className="text-center text-sm text-gray-500 py-10">
  301 + No results.
196 302 </TableCell>
197 303 </TableRow>
198   - ))}
  304 + ) : (
  305 + locations.map((loc) => (
  306 + <TableRow key={loc.id}>
  307 + <TableCell className="border-r text-gray-600 max-w-[140px] truncate">{toDisplay(loc.partner)}</TableCell>
  308 + <TableCell className="border-r text-gray-600 max-w-[140px] truncate">{toDisplay(loc.groupName)}</TableCell>
  309 + <TableCell className="border-r font-numeric text-gray-600">{toDisplay(loc.locationCode ?? loc.id)}</TableCell>
  310 + <TableCell className="border-r font-medium text-black">{toDisplay(loc.locationName)}</TableCell>
  311 + <TableCell className="border-r text-gray-600 max-w-[140px] truncate">{toDisplay(loc.street)}</TableCell>
  312 + <TableCell className="border-r text-gray-600">{toDisplay(loc.city)}</TableCell>
  313 + <TableCell className="border-r text-gray-600">{toDisplay(loc.stateCode)}</TableCell>
  314 + <TableCell className="border-r text-gray-600">{toDisplay(loc.country)}</TableCell>
  315 + <TableCell className="border-r text-gray-600 font-numeric">{toDisplay(loc.zipCode)}</TableCell>
  316 + <TableCell className="border-r text-gray-600 whitespace-nowrap">{toDisplay(loc.phone)}</TableCell>
  317 + <TableCell className="border-r text-gray-600 text-sm max-w-[180px] truncate">{toDisplay(loc.email)}</TableCell>
  318 + <TableCell className="border-r text-gray-500 font-numeric text-xs">{formatGps(loc.latitude, loc.longitude)}</TableCell>
  319 + <TableCell className="border-r">
  320 + <Badge className={loc.state ? "bg-green-600" : "bg-gray-400"}>
  321 + {loc.state ? "Yes" : "No"}
  322 + </Badge>
  323 + </TableCell>
  324 + <TableCell className="text-center">
  325 + <Popover
  326 + open={actionsOpenForId === loc.id}
  327 + onOpenChange={(open) => setActionsOpenForId(open ? loc.id : null)}
  328 + >
  329 + <PopoverTrigger asChild>
  330 + <Button
  331 + type="button"
  332 + variant="ghost"
  333 + size="icon"
  334 + className="h-8 w-8"
  335 + aria-label="Row actions"
  336 + >
  337 + <MoreHorizontal className="h-4 w-4 text-gray-500" />
  338 + </Button>
  339 + </PopoverTrigger>
  340 + <PopoverContent align="end" className="w-40 p-1">
  341 + <Button
  342 + type="button"
  343 + variant="ghost"
  344 + className="w-full justify-start gap-2 h-9 px-2 font-normal"
  345 + onClick={() => openEdit(loc)}
  346 + >
  347 + <Edit className="w-4 h-4" />
  348 + Edit
  349 + </Button>
  350 + <Button
  351 + type="button"
  352 + variant="ghost"
  353 + className="w-full justify-start h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50"
  354 + onClick={() => openDelete(loc)}
  355 + >
  356 + Delete
  357 + </Button>
  358 + </PopoverContent>
  359 + </Popover>
  360 + </TableCell>
  361 + </TableRow>
  362 + ))
  363 + )}
199 364 </TableBody>
200 365 </Table>
201 366 </div>
202 367 </div>
203 368  
204   - <CreateLocationDialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen} />
  369 + <div className="pt-4">
  370 + <div className="flex items-center justify-between text-sm text-gray-600">
  371 + <div>
  372 + Showing {total === 0 ? 0 : (pageIndex - 1) * pageSize + 1}-
  373 + {Math.min(pageIndex * pageSize, total)} of {total}
  374 + </div>
  375 + <div className="flex items-center gap-3">
  376 + <Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}>
  377 + <SelectTrigger className="w-[110px] h-9 rounded-md border border-gray-300 bg-white text-gray-900">
  378 + <SelectValue />
  379 + </SelectTrigger>
  380 + <SelectContent>
  381 + {[10, 20, 50].map((n) => (
  382 + <SelectItem key={n} value={String(n)}>
  383 + {n} / page
  384 + </SelectItem>
  385 + ))}
  386 + </SelectContent>
  387 + </Select>
  388 + <Pagination className="mx-0 w-auto justify-end">
  389 + <PaginationContent>
  390 + <PaginationItem>
  391 + <PaginationPrevious
  392 + href="#"
  393 + size="default"
  394 + onClick={(e) => {
  395 + e.preventDefault();
  396 + setPageIndex((p) => Math.max(1, p - 1));
  397 + }}
  398 + aria-disabled={pageIndex <= 1}
  399 + className={pageIndex <= 1 ? "pointer-events-none opacity-50" : ""}
  400 + />
  401 + </PaginationItem>
  402 + <PaginationItem>
  403 + <PaginationLink
  404 + href="#"
  405 + isActive
  406 + size="default"
  407 + onClick={(e) => e.preventDefault()}
  408 + >
  409 + Page {pageIndex} / {totalPages}
  410 + </PaginationLink>
  411 + </PaginationItem>
  412 + <PaginationItem>
  413 + <PaginationNext
  414 + href="#"
  415 + size="default"
  416 + onClick={(e) => {
  417 + e.preventDefault();
  418 + setPageIndex((p) => Math.min(totalPages, p + 1));
  419 + }}
  420 + aria-disabled={pageIndex >= totalPages}
  421 + className={pageIndex >= totalPages ? "pointer-events-none opacity-50" : ""}
  422 + />
  423 + </PaginationItem>
  424 + </PaginationContent>
  425 + </Pagination>
  426 + </div>
  427 + </div>
  428 + </div>
  429 +
  430 + <CreateLocationDialog
  431 + open={isCreateDialogOpen}
  432 + onOpenChange={setIsCreateDialogOpen}
  433 + onCreated={() => {
  434 + // 新增后强制刷新一次列表;如果当前已在第一页也能刷新。
  435 + setPageIndex(1);
  436 + refreshList();
  437 + }}
  438 + />
  439 +
  440 + <EditLocationDialog
  441 + open={isEditDialogOpen}
  442 + location={editingLocation}
  443 + onOpenChange={(open) => {
  444 + setIsEditDialogOpen(open);
  445 + if (!open) setEditingLocation(null);
  446 + }}
  447 + onUpdated={() => {
  448 + // 编辑后强制刷新一次列表
  449 + refreshList();
  450 + }}
  451 + />
  452 +
  453 + <DeleteLocationDialog
  454 + open={isDeleteDialogOpen}
  455 + location={deletingLocation}
  456 + onOpenChange={(open) => {
  457 + setIsDeleteDialogOpen(open);
  458 + if (!open) setDeletingLocation(null);
  459 + }}
  460 + onDeleted={() => {
  461 + refreshList();
  462 + }}
  463 + />
205 464 </div>
206 465 );
207 466 }
208 467  
209 468 // --- Sub-components ---
210 469  
211   -function CreateLocationDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
  470 +function CreateLocationDialog({
  471 + open,
  472 + onOpenChange,
  473 + onCreated,
  474 +}: {
  475 + open: boolean;
  476 + onOpenChange: (open: boolean) => void;
  477 + onCreated: () => void;
  478 +}) {
  479 + const [submitting, setSubmitting] = useState(false);
  480 + const [form, setForm] = useState<LocationCreateInput>({
  481 + partner: "",
  482 + groupName: "",
  483 + locationCode: "",
  484 + locationName: "",
  485 + street: "",
  486 + city: "",
  487 + stateCode: "",
  488 + country: "",
  489 + zipCode: "",
  490 + phone: "",
  491 + email: "",
  492 + latitude: null,
  493 + longitude: null,
  494 + state: true,
  495 + });
  496 +
  497 + const resetForm = () => {
  498 + setForm({
  499 + partner: "",
  500 + groupName: "",
  501 + locationCode: "",
  502 + locationName: "",
  503 + street: "",
  504 + city: "",
  505 + stateCode: "",
  506 + country: "",
  507 + zipCode: "",
  508 + phone: "",
  509 + email: "",
  510 + latitude: null,
  511 + longitude: null,
  512 + state: true,
  513 + });
  514 + };
  515 +
  516 + useEffect(() => {
  517 + if (!open) {
  518 + resetForm();
  519 + setSubmitting(false);
  520 + }
  521 + }, [open]);
  522 +
  523 + const canSubmit = useMemo(() => {
  524 + return form.locationCode.trim().length > 0 && form.locationName.trim().length > 0;
  525 + }, [form.locationCode, form.locationName]);
  526 +
  527 + const submit = async () => {
  528 + if (!canSubmit) {
  529 + toast.error("Please fill in required fields.", {
  530 + description: "Location ID and Location Name are required.",
  531 + });
  532 + return;
  533 + }
  534 + setSubmitting(true);
  535 + try {
  536 + await createLocation({
  537 + ...form,
  538 + locationCode: form.locationCode.trim(),
  539 + locationName: form.locationName.trim(),
  540 + partner: form.partner?.trim() ? form.partner.trim() : null,
  541 + groupName: form.groupName?.trim() ? form.groupName.trim() : null,
  542 + street: form.street?.trim() ? form.street.trim() : null,
  543 + city: form.city?.trim() ? form.city.trim() : null,
  544 + stateCode: form.stateCode?.trim() ? form.stateCode.trim() : null,
  545 + country: form.country?.trim() ? form.country.trim() : null,
  546 + zipCode: form.zipCode?.trim() ? form.zipCode.trim() : null,
  547 + phone: form.phone?.trim() ? form.phone.trim() : null,
  548 + email: form.email?.trim() ? form.email.trim() : null,
  549 + });
  550 + toast.success("Location created.", {
  551 + description: "The location has been added successfully.",
  552 + });
  553 + onOpenChange(false);
  554 + onCreated();
  555 + } catch (e: any) {
  556 + toast.error("Failed to create location.", {
  557 + description: e?.message ? String(e.message) : "Please try again.",
  558 + });
  559 + } finally {
  560 + setSubmitting(false);
  561 + }
  562 + };
  563 +
212 564 return (
213 565 <Dialog open={open} onOpenChange={onOpenChange}>
214 566 <DialogContent className="sm:max-w-[600px]">
... ... @@ -223,70 +575,104 @@ function CreateLocationDialog({ open, onOpenChange }: { open: boolean; onOpenCha
223 575 <div className="grid grid-cols-2 gap-4">
224 576 <div className="space-y-2">
225 577 <Label>Partner</Label>
226   - <Select defaultValue="partner-a">
227   - <SelectTrigger><SelectValue /></SelectTrigger>
228   - <SelectContent>
229   - <SelectItem value="partner-a">Partner A</SelectItem>
230   - </SelectContent>
231   - </Select>
  578 + <Input
  579 + placeholder="e.g. Global Foods Inc."
  580 + value={form.partner ?? ""}
  581 + onChange={(e) => setForm((p) => ({ ...p, partner: e.target.value }))}
  582 + />
232 583 </div>
233 584 <div className="space-y-2">
234 585 <Label>Group</Label>
235   - <Select defaultValue="group-b">
236   - <SelectTrigger><SelectValue /></SelectTrigger>
237   - <SelectContent>
238   - <SelectItem value="group-b">Group B</SelectItem>
239   - </SelectContent>
240   - </Select>
  586 + <Input
  587 + placeholder="e.g. East Coast Region"
  588 + value={form.groupName ?? ""}
  589 + onChange={(e) => setForm((p) => ({ ...p, groupName: e.target.value }))}
  590 + />
241 591 </div>
242 592 </div>
243 593  
244 594 <div className="grid grid-cols-3 gap-4">
245 595 <div className="space-y-2 col-span-1">
246 596 <Label>Location ID</Label>
247   - <Input placeholder="e.g. 12345" />
  597 + <Input
  598 + placeholder="e.g. 12345"
  599 + value={form.locationCode}
  600 + onChange={(e) => setForm((p) => ({ ...p, locationCode: e.target.value }))}
  601 + />
248 602 </div>
249 603 <div className="space-y-2 col-span-2">
250 604 <Label>Location Name</Label>
251   - <Input placeholder="e.g. Downtown Store" />
  605 + <Input
  606 + placeholder="e.g. Downtown Store"
  607 + value={form.locationName}
  608 + onChange={(e) => setForm((p) => ({ ...p, locationName: e.target.value }))}
  609 + />
252 610 </div>
253 611 </div>
254 612  
255 613 <div className="space-y-2">
256 614 <Label>Street</Label>
257   - <Input placeholder="e.g. 123 Main St" />
  615 + <Input
  616 + placeholder="e.g. 123 Main St"
  617 + value={form.street ?? ""}
  618 + onChange={(e) => setForm((p) => ({ ...p, street: e.target.value }))}
  619 + />
258 620 </div>
259 621  
260 622 <div className="grid grid-cols-2 gap-4">
261 623 <div className="space-y-2">
262 624 <Label>City</Label>
263   - <Input placeholder="e.g. New York" />
  625 + <Input
  626 + placeholder="e.g. New York"
  627 + value={form.city ?? ""}
  628 + onChange={(e) => setForm((p) => ({ ...p, city: e.target.value }))}
  629 + />
264 630 </div>
265 631 <div className="space-y-2">
266 632 <Label>State</Label>
267   - <Input placeholder="e.g. NY" />
  633 + <Input
  634 + placeholder="e.g. NY"
  635 + value={form.stateCode ?? ""}
  636 + onChange={(e) => setForm((p) => ({ ...p, stateCode: e.target.value }))}
  637 + />
268 638 </div>
269 639 </div>
270 640  
271 641 <div className="grid grid-cols-2 gap-4">
272 642 <div className="space-y-2">
273 643 <Label>Country</Label>
274   - <Input placeholder="e.g. USA" />
  644 + <Input
  645 + placeholder="e.g. USA"
  646 + value={form.country ?? ""}
  647 + onChange={(e) => setForm((p) => ({ ...p, country: e.target.value }))}
  648 + />
275 649 </div>
276 650 <div className="space-y-2">
277 651 <Label>Zip Code</Label>
278   - <Input placeholder="e.g. 10001" />
  652 + <Input
  653 + placeholder="e.g. 10001"
  654 + value={form.zipCode ?? ""}
  655 + onChange={(e) => setForm((p) => ({ ...p, zipCode: e.target.value }))}
  656 + />
279 657 </div>
280 658 </div>
281 659  
282 660 <div className="grid grid-cols-2 gap-4">
283 661 <div className="space-y-2">
284 662 <Label>Phone Number</Label>
285   - <Input placeholder="+1 (555) 000-0000" />
  663 + <Input
  664 + placeholder="+1 (555) 000-0000"
  665 + value={form.phone ?? ""}
  666 + onChange={(e) => setForm((p) => ({ ...p, phone: e.target.value }))}
  667 + />
286 668 </div>
287 669 <div className="space-y-2">
288 670 <Label>Email</Label>
289   - <Input placeholder="store@example.com" />
  671 + <Input
  672 + placeholder="store@example.com"
  673 + value={form.email ?? ""}
  674 + onChange={(e) => setForm((p) => ({ ...p, email: e.target.value }))}
  675 + />
290 676 </div>
291 677 </div>
292 678  
... ... @@ -295,13 +681,31 @@ function CreateLocationDialog({ open, onOpenChange }: { open: boolean; onOpenCha
295 681 <MapPin className="w-4 h-4" /> GPS Coordinates
296 682 </Label>
297 683 <div className="grid grid-cols-2 gap-4">
298   - <Input placeholder="Latitude (e.g. 40.7128)" />
299   - <Input placeholder="Longitude (e.g. -74.0060)" />
  684 + <Input
  685 + placeholder="Latitude (e.g. 40.7128)"
  686 + value={form.latitude === null || form.latitude === undefined ? "" : String(form.latitude)}
  687 + onChange={(e) => {
  688 + const raw = e.target.value.trim();
  689 + setForm((p) => ({ ...p, latitude: raw ? Number(raw) : null }));
  690 + }}
  691 + />
  692 + <Input
  693 + placeholder="Longitude (e.g. -74.0060)"
  694 + value={form.longitude === null || form.longitude === undefined ? "" : String(form.longitude)}
  695 + onChange={(e) => {
  696 + const raw = e.target.value.trim();
  697 + setForm((p) => ({ ...p, longitude: raw ? Number(raw) : null }));
  698 + }}
  699 + />
300 700 </div>
301 701 </div>
302 702  
303 703 <div className="flex items-center gap-2 pt-2">
304   - <Switch id="loc-status" defaultChecked />
  704 + <Switch
  705 + id="loc-status"
  706 + checked={!!form.state}
  707 + onCheckedChange={(v) => setForm((p) => ({ ...p, state: v }))}
  708 + />
305 709 <Label htmlFor="loc-status">Active Location</Label>
306 710 </div>
307 711  
... ... @@ -309,7 +713,346 @@ function CreateLocationDialog({ open, onOpenChange }: { open: boolean; onOpenCha
309 713  
310 714 <DialogFooter>
311 715 <Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
312   - <Button onClick={() => onOpenChange(false)} className="bg-blue-600 text-white hover:bg-blue-700">Create Location</Button>
  716 + <Button
  717 + disabled={submitting}
  718 + onClick={submit}
  719 + className="bg-blue-600 text-white hover:bg-blue-700"
  720 + >
  721 + {submitting ? "Creating..." : "Create Location"}
  722 + </Button>
  723 + </DialogFooter>
  724 + </DialogContent>
  725 + </Dialog>
  726 + );
  727 +}
  728 +
  729 +function fromDtoToForm(loc: LocationDto): LocationCreateInput {
  730 + return {
  731 + partner: loc.partner ?? "",
  732 + groupName: loc.groupName ?? "",
  733 + locationCode: loc.locationCode ?? "",
  734 + locationName: loc.locationName ?? "",
  735 + street: loc.street ?? "",
  736 + city: loc.city ?? "",
  737 + stateCode: loc.stateCode ?? "",
  738 + country: loc.country ?? "",
  739 + zipCode: loc.zipCode ?? "",
  740 + phone: loc.phone ?? "",
  741 + email: loc.email ?? "",
  742 + latitude: loc.latitude ?? null,
  743 + longitude: loc.longitude ?? null,
  744 + state: !!loc.state,
  745 + };
  746 +}
  747 +
  748 +function EditLocationDialog({
  749 + open,
  750 + location,
  751 + onOpenChange,
  752 + onUpdated,
  753 +}: {
  754 + open: boolean;
  755 + location: LocationDto | null;
  756 + onOpenChange: (open: boolean) => void;
  757 + onUpdated: () => void;
  758 +}) {
  759 + const [submitting, setSubmitting] = useState(false);
  760 + const [form, setForm] = useState<LocationCreateInput>({
  761 + partner: "",
  762 + groupName: "",
  763 + locationCode: "",
  764 + locationName: "",
  765 + street: "",
  766 + city: "",
  767 + stateCode: "",
  768 + country: "",
  769 + zipCode: "",
  770 + phone: "",
  771 + email: "",
  772 + latitude: null,
  773 + longitude: null,
  774 + state: true,
  775 + });
  776 +
  777 + useEffect(() => {
  778 + if (open && location) {
  779 + setForm(fromDtoToForm(location));
  780 + setSubmitting(false);
  781 + }
  782 + if (!open) setSubmitting(false);
  783 + }, [open, location]);
  784 +
  785 + const canSubmit = useMemo(() => {
  786 + return form.locationCode.trim().length > 0 && form.locationName.trim().length > 0;
  787 + }, [form.locationCode, form.locationName]);
  788 +
  789 + const submit = async () => {
  790 + if (!location?.id) return;
  791 + if (!canSubmit) {
  792 + toast.error("Please fill in required fields.", {
  793 + description: "Location ID and Location Name are required.",
  794 + });
  795 + return;
  796 + }
  797 +
  798 + setSubmitting(true);
  799 + try {
  800 + await updateLocation(location.id, {
  801 + ...form,
  802 + locationCode: form.locationCode.trim(),
  803 + locationName: form.locationName.trim(),
  804 + partner: form.partner?.trim() ? form.partner.trim() : null,
  805 + groupName: form.groupName?.trim() ? form.groupName.trim() : null,
  806 + street: form.street?.trim() ? form.street.trim() : null,
  807 + city: form.city?.trim() ? form.city.trim() : null,
  808 + stateCode: form.stateCode?.trim() ? form.stateCode.trim() : null,
  809 + country: form.country?.trim() ? form.country.trim() : null,
  810 + zipCode: form.zipCode?.trim() ? form.zipCode.trim() : null,
  811 + phone: form.phone?.trim() ? form.phone.trim() : null,
  812 + email: form.email?.trim() ? form.email.trim() : null,
  813 + });
  814 +
  815 + toast.success("Location updated.", {
  816 + description: "The changes have been saved successfully.",
  817 + });
  818 + onOpenChange(false);
  819 + onUpdated();
  820 + } catch (e: any) {
  821 + toast.error("Failed to update location.", {
  822 + description: e?.message ? String(e.message) : "Please try again.",
  823 + });
  824 + } finally {
  825 + setSubmitting(false);
  826 + }
  827 + };
  828 +
  829 + return (
  830 + <Dialog open={open} onOpenChange={onOpenChange}>
  831 + <DialogContent className="sm:max-w-[600px]">
  832 + <DialogHeader>
  833 + <DialogTitle>Edit Location</DialogTitle>
  834 + <DialogDescription>
  835 + Update the details for this store location.
  836 + </DialogDescription>
  837 + </DialogHeader>
  838 +
  839 + <div className="grid gap-4 py-4">
  840 + <div className="grid grid-cols-2 gap-4">
  841 + <div className="space-y-2">
  842 + <Label>Partner</Label>
  843 + <Input
  844 + placeholder="e.g. Global Foods Inc."
  845 + value={form.partner ?? ""}
  846 + onChange={(e) => setForm((p) => ({ ...p, partner: e.target.value }))}
  847 + />
  848 + </div>
  849 + <div className="space-y-2">
  850 + <Label>Group</Label>
  851 + <Input
  852 + placeholder="e.g. East Coast Region"
  853 + value={form.groupName ?? ""}
  854 + onChange={(e) => setForm((p) => ({ ...p, groupName: e.target.value }))}
  855 + />
  856 + </div>
  857 + </div>
  858 +
  859 + <div className="grid grid-cols-3 gap-4">
  860 + <div className="space-y-2 col-span-1">
  861 + <Label>Location ID</Label>
  862 + <Input
  863 + placeholder="e.g. 12345"
  864 + value={form.locationCode}
  865 + onChange={(e) => setForm((p) => ({ ...p, locationCode: e.target.value }))}
  866 + />
  867 + </div>
  868 + <div className="space-y-2 col-span-2">
  869 + <Label>Location Name</Label>
  870 + <Input
  871 + placeholder="e.g. Downtown Store"
  872 + value={form.locationName}
  873 + onChange={(e) => setForm((p) => ({ ...p, locationName: e.target.value }))}
  874 + />
  875 + </div>
  876 + </div>
  877 +
  878 + <div className="space-y-2">
  879 + <Label>Street</Label>
  880 + <Input
  881 + placeholder="e.g. 123 Main St"
  882 + value={form.street ?? ""}
  883 + onChange={(e) => setForm((p) => ({ ...p, street: e.target.value }))}
  884 + />
  885 + </div>
  886 +
  887 + <div className="grid grid-cols-2 gap-4">
  888 + <div className="space-y-2">
  889 + <Label>City</Label>
  890 + <Input
  891 + placeholder="e.g. New York"
  892 + value={form.city ?? ""}
  893 + onChange={(e) => setForm((p) => ({ ...p, city: e.target.value }))}
  894 + />
  895 + </div>
  896 + <div className="space-y-2">
  897 + <Label>State</Label>
  898 + <Input
  899 + placeholder="e.g. NY"
  900 + value={form.stateCode ?? ""}
  901 + onChange={(e) => setForm((p) => ({ ...p, stateCode: e.target.value }))}
  902 + />
  903 + </div>
  904 + </div>
  905 +
  906 + <div className="grid grid-cols-2 gap-4">
  907 + <div className="space-y-2">
  908 + <Label>Country</Label>
  909 + <Input
  910 + placeholder="e.g. USA"
  911 + value={form.country ?? ""}
  912 + onChange={(e) => setForm((p) => ({ ...p, country: e.target.value }))}
  913 + />
  914 + </div>
  915 + <div className="space-y-2">
  916 + <Label>Zip Code</Label>
  917 + <Input
  918 + placeholder="e.g. 10001"
  919 + value={form.zipCode ?? ""}
  920 + onChange={(e) => setForm((p) => ({ ...p, zipCode: e.target.value }))}
  921 + />
  922 + </div>
  923 + </div>
  924 +
  925 + <div className="grid grid-cols-2 gap-4">
  926 + <div className="space-y-2">
  927 + <Label>Phone Number</Label>
  928 + <Input
  929 + placeholder="+1 (555) 000-0000"
  930 + value={form.phone ?? ""}
  931 + onChange={(e) => setForm((p) => ({ ...p, phone: e.target.value }))}
  932 + />
  933 + </div>
  934 + <div className="space-y-2">
  935 + <Label>Email</Label>
  936 + <Input
  937 + placeholder="store@example.com"
  938 + value={form.email ?? ""}
  939 + onChange={(e) => setForm((p) => ({ ...p, email: e.target.value }))}
  940 + />
  941 + </div>
  942 + </div>
  943 +
  944 + <div className="space-y-2">
  945 + <Label className="flex items-center gap-2">
  946 + <MapPin className="w-4 h-4" /> GPS Coordinates
  947 + </Label>
  948 + <div className="grid grid-cols-2 gap-4">
  949 + <Input
  950 + placeholder="Latitude (e.g. 40.7128)"
  951 + value={form.latitude === null || form.latitude === undefined ? "" : String(form.latitude)}
  952 + onChange={(e) => {
  953 + const raw = e.target.value.trim();
  954 + setForm((p) => ({ ...p, latitude: raw ? Number(raw) : null }));
  955 + }}
  956 + />
  957 + <Input
  958 + placeholder="Longitude (e.g. -74.0060)"
  959 + value={form.longitude === null || form.longitude === undefined ? "" : String(form.longitude)}
  960 + onChange={(e) => {
  961 + const raw = e.target.value.trim();
  962 + setForm((p) => ({ ...p, longitude: raw ? Number(raw) : null }));
  963 + }}
  964 + />
  965 + </div>
  966 + </div>
  967 +
  968 + <div className="flex items-center gap-2 pt-2">
  969 + <Switch
  970 + id="loc-status-edit"
  971 + checked={!!form.state}
  972 + onCheckedChange={(v) => setForm((p) => ({ ...p, state: v }))}
  973 + />
  974 + <Label htmlFor="loc-status-edit">Active Location</Label>
  975 + </div>
  976 + </div>
  977 +
  978 + <DialogFooter>
  979 + <Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
  980 + <Button
  981 + disabled={submitting}
  982 + onClick={submit}
  983 + className="bg-blue-600 text-white hover:bg-blue-700"
  984 + >
  985 + {submitting ? "Saving..." : "Save Changes"}
  986 + </Button>
  987 + </DialogFooter>
  988 + </DialogContent>
  989 + </Dialog>
  990 + );
  991 +}
  992 +
  993 +function DeleteLocationDialog({
  994 + open,
  995 + location,
  996 + onOpenChange,
  997 + onDeleted,
  998 +}: {
  999 + open: boolean;
  1000 + location: LocationDto | null;
  1001 + onOpenChange: (open: boolean) => void;
  1002 + onDeleted: () => void;
  1003 +}) {
  1004 + const [submitting, setSubmitting] = useState(false);
  1005 +
  1006 + const name = useMemo(() => {
  1007 + const code = (location?.locationCode ?? "").trim();
  1008 + const n = (location?.locationName ?? "").trim();
  1009 + if (code && n) return `${code} - ${n}`;
  1010 + return code || n || "this location";
  1011 + }, [location?.locationCode, location?.locationName]);
  1012 +
  1013 + const submit = async () => {
  1014 + if (!location?.id) return;
  1015 + setSubmitting(true);
  1016 + try {
  1017 + await deleteLocation(location.id);
  1018 + toast.success("Location deleted.", {
  1019 + description: "The location has been removed successfully.",
  1020 + });
  1021 + onOpenChange(false);
  1022 + onDeleted();
  1023 + } catch (e: any) {
  1024 + toast.error("Failed to delete location.", {
  1025 + description: e?.message ? String(e.message) : "Please try again.",
  1026 + });
  1027 + } finally {
  1028 + setSubmitting(false);
  1029 + }
  1030 + };
  1031 +
  1032 + return (
  1033 + <Dialog open={open} onOpenChange={onOpenChange}>
  1034 + <DialogContent className="sm:max-w-none" style={{ width: "30%" }}>
  1035 + <DialogHeader>
  1036 + <DialogTitle>Delete Location</DialogTitle>
  1037 + <DialogDescription>
  1038 + This action cannot be undone.
  1039 + </DialogDescription>
  1040 + </DialogHeader>
  1041 +
  1042 + <div className="text-sm text-gray-700">
  1043 + Are you sure you want to delete <span className="font-medium">{name}</span>?
  1044 + </div>
  1045 +
  1046 + <DialogFooter className="flex-row flex-wrap justify-end">
  1047 + <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
  1048 + <Button
  1049 + className="min-w-24"
  1050 + variant="destructive"
  1051 + disabled={submitting}
  1052 + onClick={submit}
  1053 + >
  1054 + {submitting ? "Deleting..." : "Delete"}
  1055 + </Button>
313 1056 </DialogFooter>
314 1057 </DialogContent>
315 1058 </Dialog>
... ...
美国版/Food Labeling Management Platform/src/components/menus/MenuManagementView.tsx 0 → 100644
  1 +import React, { useEffect, useMemo, useRef, useState } from "react";
  2 +import { Edit, MoreHorizontal, Plus, Trash2 } from "lucide-react";
  3 +import { toast } from "sonner";
  4 +
  5 +import { Button } from "../ui/button";
  6 +import { Input } from "../ui/input";
  7 +import { Label } from "../ui/label";
  8 +import { Switch } from "../ui/switch";
  9 +import {
  10 + Dialog,
  11 + DialogContent,
  12 + DialogDescription,
  13 + DialogFooter,
  14 + DialogHeader,
  15 + DialogTitle,
  16 +} from "../ui/dialog";
  17 +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
  18 +import {
  19 + Table,
  20 + TableBody,
  21 + TableCell,
  22 + TableHead,
  23 + TableHeader,
  24 + TableRow,
  25 +} from "../ui/table";
  26 +import {
  27 + Pagination,
  28 + PaginationContent,
  29 + PaginationItem,
  30 + PaginationLink,
  31 + PaginationNext,
  32 + PaginationPrevious,
  33 +} from "../ui/pagination";
  34 +
  35 +import { createMenu, deleteMenu, getMenus, updateMenu } from "../../services/menuService";
  36 +import type { MenuCreateInput, MenuDto } from "../../types/menu";
  37 +
  38 +function toDisplay(v: string | null | undefined): string {
  39 + const s = (v ?? "").trim();
  40 + return s ? s : "N/A";
  41 +}
  42 +
  43 +function toNumberOrNull(v: string): number | null {
  44 + const s = v.trim();
  45 + if (!s) return null;
  46 + const n = Number(s);
  47 + return Number.isFinite(n) ? n : null;
  48 +}
  49 +
  50 +function formatDateTime(v: string | null | undefined): string {
  51 + const s = (v ?? "").trim();
  52 + if (!s) return "N/A";
  53 + const d = new Date(s);
  54 + if (Number.isNaN(d.getTime())) return s;
  55 + return d.toLocaleString();
  56 +}
  57 +
  58 +export function MenuManagementView() {
  59 + const [menus, setMenus] = useState<MenuDto[]>([]);
  60 + const [loading, setLoading] = useState(false);
  61 + const [total, setTotal] = useState(0);
  62 + const [refreshSeq, setRefreshSeq] = useState(0);
  63 + const [actionsOpenForId, setActionsOpenForId] = useState<string | null>(null);
  64 +
  65 + const [keyword, setKeyword] = useState("");
  66 + const keywordTimerRef = useRef<number | null>(null);
  67 + const [debouncedKeyword, setDebouncedKeyword] = useState("");
  68 +
  69 + const [pageIndex, setPageIndex] = useState(1);
  70 + const [pageSize] = useState(10);
  71 +
  72 + const [isCreateOpen, setIsCreateOpen] = useState(false);
  73 + const [isEditOpen, setIsEditOpen] = useState(false);
  74 + const [isDeleteOpen, setIsDeleteOpen] = useState(false);
  75 + const [editing, setEditing] = useState<MenuDto | null>(null);
  76 + const [deleting, setDeleting] = useState<MenuDto | null>(null);
  77 +
  78 + const abortRef = useRef<AbortController | null>(null);
  79 +
  80 + useEffect(() => {
  81 + if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current);
  82 + keywordTimerRef.current = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300);
  83 + return () => {
  84 + if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current);
  85 + };
  86 + }, [keyword]);
  87 +
  88 + useEffect(() => {
  89 + setPageIndex(1);
  90 + }, [debouncedKeyword]);
  91 +
  92 + const totalPages = Math.max(1, Math.ceil(total / pageSize));
  93 +
  94 + useEffect(() => {
  95 + const run = async () => {
  96 + abortRef.current?.abort();
  97 + const ac = new AbortController();
  98 + abortRef.current = ac;
  99 +
  100 + setLoading(true);
  101 + try {
  102 + const skipCount = (pageIndex - 1) * pageSize;
  103 + const res = await getMenus(
  104 + {
  105 + skipCount,
  106 + maxResultCount: pageSize,
  107 + keyword: debouncedKeyword || undefined,
  108 + },
  109 + ac.signal,
  110 + );
  111 + setMenus(res.items ?? []);
  112 + setTotal(res.totalCount ?? 0);
  113 + } catch (e: any) {
  114 + if (e?.name === "AbortError") return;
  115 + toast.error("Failed to load menus.", {
  116 + description: e?.message ? String(e.message) : "Please try again.",
  117 + });
  118 + setMenus([]);
  119 + setTotal(0);
  120 + } finally {
  121 + setLoading(false);
  122 + }
  123 + };
  124 +
  125 + run();
  126 + return () => abortRef.current?.abort();
  127 + }, [debouncedKeyword, pageIndex, pageSize, refreshSeq]);
  128 +
  129 + const refreshList = () => setRefreshSeq((x) => x + 1);
  130 +
  131 + const openEdit = (m: MenuDto) => {
  132 + setActionsOpenForId(null);
  133 + setEditing(m);
  134 + setIsEditOpen(true);
  135 + };
  136 +
  137 + const openDelete = (m: MenuDto) => {
  138 + setActionsOpenForId(null);
  139 + setDeleting(m);
  140 + setIsDeleteOpen(true);
  141 + };
  142 +
  143 + return (
  144 + <div className="h-full flex flex-col">
  145 + <div className="pb-4">
  146 + <div className="flex flex-col gap-4">
  147 + <div className="flex flex-nowrap items-center gap-3">
  148 + <Input
  149 + placeholder="Search"
  150 + value={keyword}
  151 + onChange={(e) => setKeyword(e.target.value)}
  152 + style={{ height: 40, boxSizing: "border-box" }}
  153 + className="border border-gray-300 rounded-md w-40 shrink-0 bg-white placeholder:text-gray-500"
  154 + />
  155 +
  156 + <div className="flex-1" />
  157 +
  158 + <Button
  159 + className="bg-blue-600 text-white hover:bg-blue-700"
  160 + onClick={() => setIsCreateOpen(true)}
  161 + >
  162 + <Plus className="w-4 h-4 mr-2" />
  163 + New Menu
  164 + </Button>
  165 + </div>
  166 + </div>
  167 + </div>
  168 +
  169 + <div className="flex-1 bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
  170 + <div className="h-full overflow-auto">
  171 + <Table>
  172 + <TableHeader className="bg-gray-50 sticky top-0 z-10">
  173 + <TableRow className="hover:bg-gray-50">
  174 + <TableHead className="font-semibold text-gray-900">Name</TableHead>
  175 + <TableHead className="font-semibold text-gray-900">Path</TableHead>
  176 + <TableHead className="font-semibold text-gray-900">Icon</TableHead>
  177 + <TableHead className="font-semibold text-gray-900">Order</TableHead>
  178 + <TableHead className="font-semibold text-gray-900">Parent ID</TableHead>
  179 + <TableHead className="font-semibold text-gray-900">Enabled</TableHead>
  180 + <TableHead className="font-semibold text-gray-900">Created At</TableHead>
  181 + <TableHead className="font-semibold text-gray-900 w-16">Actions</TableHead>
  182 + </TableRow>
  183 + </TableHeader>
  184 +
  185 + <TableBody>
  186 + {menus.length === 0 ? (
  187 + <TableRow>
  188 + <TableCell colSpan={8} className="text-center py-10 text-gray-500">
  189 + {loading ? "Loading..." : "No data"}
  190 + </TableCell>
  191 + </TableRow>
  192 + ) : (
  193 + menus.map((m) => (
  194 + <TableRow key={m.id} className="hover:bg-gray-50">
  195 + <TableCell className="font-medium text-gray-900">{toDisplay(m.name)}</TableCell>
  196 + <TableCell className="text-gray-700">{toDisplay(m.path)}</TableCell>
  197 + <TableCell className="text-gray-700">{toDisplay(m.icon)}</TableCell>
  198 + <TableCell className="text-gray-700">{m.order ?? "N/A"}</TableCell>
  199 + <TableCell className="text-gray-700">{toDisplay(m.parentId)}</TableCell>
  200 + <TableCell className="text-gray-700">{m.isEnabled ? "Yes" : "No"}</TableCell>
  201 + <TableCell className="text-gray-700">{formatDateTime(m.createdAt)}</TableCell>
  202 + <TableCell className="text-right">
  203 + <Popover
  204 + open={actionsOpenForId === m.id}
  205 + onOpenChange={(open) => setActionsOpenForId(open ? m.id : null)}
  206 + >
  207 + <PopoverTrigger asChild>
  208 + <Button variant="ghost" size="icon" className="h-8 w-8">
  209 + <MoreHorizontal className="h-4 w-4" />
  210 + </Button>
  211 + </PopoverTrigger>
  212 + <PopoverContent className="w-44 p-2" align="end">
  213 + <div className="flex flex-col">
  214 + <Button
  215 + variant="ghost"
  216 + className="justify-start"
  217 + onClick={() => openEdit(m)}
  218 + >
  219 + <Edit className="w-4 h-4 mr-2" />
  220 + Edit
  221 + </Button>
  222 + <Button
  223 + variant="ghost"
  224 + className="justify-start text-red-600 hover:text-red-700"
  225 + onClick={() => openDelete(m)}
  226 + >
  227 + <Trash2 className="w-4 h-4 mr-2" />
  228 + Delete
  229 + </Button>
  230 + </div>
  231 + </PopoverContent>
  232 + </Popover>
  233 + </TableCell>
  234 + </TableRow>
  235 + ))
  236 + )}
  237 + </TableBody>
  238 + </Table>
  239 + </div>
  240 +
  241 + <div className="px-4 py-3 border-t border-gray-200 bg-white flex items-center justify-between">
  242 + <div className="text-sm text-gray-600">
  243 + {total === 0 ? "0 results" : `${total} results`}
  244 + </div>
  245 +
  246 + <Pagination>
  247 + <PaginationContent>
  248 + <PaginationItem>
  249 + <PaginationPrevious
  250 + href="#"
  251 + onClick={(e) => {
  252 + e.preventDefault();
  253 + setPageIndex((p) => Math.max(1, p - 1));
  254 + }}
  255 + />
  256 + </PaginationItem>
  257 + {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
  258 + const page = i + 1;
  259 + return (
  260 + <PaginationItem key={page}>
  261 + <PaginationLink
  262 + href="#"
  263 + isActive={pageIndex === page}
  264 + onClick={(e) => {
  265 + e.preventDefault();
  266 + setPageIndex(page);
  267 + }}
  268 + >
  269 + {page}
  270 + </PaginationLink>
  271 + </PaginationItem>
  272 + );
  273 + })}
  274 + <PaginationItem>
  275 + <PaginationNext
  276 + href="#"
  277 + onClick={(e) => {
  278 + e.preventDefault();
  279 + setPageIndex((p) => Math.min(totalPages, p + 1));
  280 + }}
  281 + />
  282 + </PaginationItem>
  283 + </PaginationContent>
  284 + </Pagination>
  285 + </div>
  286 + </div>
  287 +
  288 + <CreateOrEditMenuDialog
  289 + mode="create"
  290 + open={isCreateOpen}
  291 + menu={null}
  292 + onOpenChange={(open) => setIsCreateOpen(open)}
  293 + onSaved={refreshList}
  294 + />
  295 +
  296 + <CreateOrEditMenuDialog
  297 + mode="edit"
  298 + open={isEditOpen}
  299 + menu={editing}
  300 + onOpenChange={(open) => setIsEditOpen(open)}
  301 + onSaved={refreshList}
  302 + />
  303 +
  304 + <DeleteMenuDialog
  305 + open={isDeleteOpen}
  306 + menu={deleting}
  307 + onOpenChange={(open) => setIsDeleteOpen(open)}
  308 + onDeleted={refreshList}
  309 + />
  310 + </div>
  311 + );
  312 +}
  313 +
  314 +function CreateOrEditMenuDialog({
  315 + mode,
  316 + open,
  317 + menu,
  318 + onOpenChange,
  319 + onSaved,
  320 +}: {
  321 + mode: "create" | "edit";
  322 + open: boolean;
  323 + menu: MenuDto | null;
  324 + onOpenChange: (open: boolean) => void;
  325 + onSaved: () => void;
  326 +}) {
  327 + const isEdit = mode === "edit";
  328 + const [submitting, setSubmitting] = useState(false);
  329 +
  330 + const [name, setName] = useState("");
  331 + const [path, setPath] = useState("");
  332 + const [icon, setIcon] = useState("");
  333 + const [order, setOrder] = useState("");
  334 + const [parentId, setParentId] = useState("");
  335 + const [isEnabled, setIsEnabled] = useState(true);
  336 +
  337 + useEffect(() => {
  338 + if (!open) return;
  339 + setName(menu?.name ?? "");
  340 + setPath(menu?.path ?? "");
  341 + setIcon(menu?.icon ?? "");
  342 + setOrder(menu?.order === null || menu?.order === undefined ? "" : String(menu.order));
  343 + setParentId(menu?.parentId ?? "");
  344 + setIsEnabled(menu?.isEnabled ?? true);
  345 + }, [open, menu]);
  346 +
  347 + const canSubmit = useMemo(() => {
  348 + return Boolean(name.trim() && path.trim());
  349 + }, [name, path]);
  350 +
  351 + const submit = async () => {
  352 + if (!canSubmit) {
  353 + toast.error("Please fill in required fields.", {
  354 + description: "Name and Path are required.",
  355 + });
  356 + return;
  357 + }
  358 + setSubmitting(true);
  359 + try {
  360 + const payload: MenuCreateInput = {
  361 + name: name.trim(),
  362 + path: path.trim(),
  363 + icon: icon.trim() ? icon.trim() : null,
  364 + order: toNumberOrNull(order),
  365 + parentId: parentId.trim() ? parentId.trim() : null,
  366 + isEnabled,
  367 + };
  368 +
  369 + if (isEdit) {
  370 + if (!menu?.id) throw new Error("Missing menu id.");
  371 + await updateMenu(menu.id, payload);
  372 + toast.success("Menu updated.", { description: "Changes have been saved successfully." });
  373 + } else {
  374 + await createMenu(payload);
  375 + toast.success("Menu created.", { description: "A new menu has been created successfully." });
  376 + }
  377 + onOpenChange(false);
  378 + onSaved();
  379 + } catch (e: any) {
  380 + toast.error(isEdit ? "Failed to update menu." : "Failed to create menu.", {
  381 + description: e?.message ? String(e.message) : "Please try again.",
  382 + });
  383 + } finally {
  384 + setSubmitting(false);
  385 + }
  386 + };
  387 +
  388 + return (
  389 + <Dialog open={open} onOpenChange={onOpenChange}>
  390 + <DialogContent className="sm:max-w-none" style={{ width: "60%" }}>
  391 + <DialogHeader>
  392 + <DialogTitle>{isEdit ? "Edit Menu" : "New Menu"}</DialogTitle>
  393 + <DialogDescription>
  394 + {isEdit ? "Update menu fields and save changes." : "Fill out the form to create a new menu."}
  395 + </DialogDescription>
  396 + </DialogHeader>
  397 +
  398 + <div className="grid grid-cols-2 gap-6 py-2">
  399 + <div className="space-y-2">
  400 + <Label htmlFor="menu-name">Name</Label>
  401 + <Input id="menu-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Dashboard" />
  402 + </div>
  403 +
  404 + <div className="space-y-2">
  405 + <Label htmlFor="menu-path">Path</Label>
  406 + <Input id="menu-path" value={path} onChange={(e) => setPath(e.target.value)} placeholder="e.g. /dashboard" />
  407 + </div>
  408 +
  409 + <div className="space-y-2">
  410 + <Label htmlFor="menu-icon">Icon</Label>
  411 + <Input id="menu-icon" value={icon} onChange={(e) => setIcon(e.target.value)} placeholder="e.g. LayoutDashboard" />
  412 + </div>
  413 +
  414 + <div className="space-y-2">
  415 + <Label htmlFor="menu-order">Order</Label>
  416 + <Input id="menu-order" value={order} onChange={(e) => setOrder(e.target.value)} placeholder="e.g. 10" />
  417 + </div>
  418 +
  419 + <div className="space-y-2">
  420 + <Label htmlFor="menu-parentId">Parent ID</Label>
  421 + <Input id="menu-parentId" value={parentId} onChange={(e) => setParentId(e.target.value)} placeholder="Optional" />
  422 + </div>
  423 +
  424 + <div className="flex items-center justify-between border border-gray-200 rounded-md px-3 bg-white" style={{ height: 40, boxSizing: "border-box" }}>
  425 + <div className="text-sm font-medium text-gray-900">Enabled</div>
  426 + <Switch checked={isEnabled} onCheckedChange={setIsEnabled} />
  427 + </div>
  428 + </div>
  429 +
  430 + <DialogFooter className="flex-row flex-wrap justify-end">
  431 + <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}>
  432 + Cancel
  433 + </Button>
  434 + <Button
  435 + className="min-w-24 bg-blue-600 text-white hover:bg-blue-700"
  436 + disabled={submitting}
  437 + onClick={submit}
  438 + >
  439 + {submitting ? "Saving..." : isEdit ? "Save Changes" : "Create"}
  440 + </Button>
  441 + </DialogFooter>
  442 + </DialogContent>
  443 + </Dialog>
  444 + );
  445 +}
  446 +
  447 +function DeleteMenuDialog({
  448 + open,
  449 + menu,
  450 + onOpenChange,
  451 + onDeleted,
  452 +}: {
  453 + open: boolean;
  454 + menu: MenuDto | null;
  455 + onOpenChange: (open: boolean) => void;
  456 + onDeleted: () => void;
  457 +}) {
  458 + const [submitting, setSubmitting] = useState(false);
  459 +
  460 + const name = useMemo(() => {
  461 + const n = (menu?.name ?? "").trim();
  462 + const p = (menu?.path ?? "").trim();
  463 + if (n && p) return `${n} (${p})`;
  464 + return n || p || "this menu";
  465 + }, [menu?.name, menu?.path]);
  466 +
  467 + const submit = async () => {
  468 + if (!menu?.id) return;
  469 + setSubmitting(true);
  470 + try {
  471 + await deleteMenu(menu.id);
  472 + toast.success("Menu deleted.", { description: "The menu has been removed successfully." });
  473 + onOpenChange(false);
  474 + onDeleted();
  475 + } catch (e: any) {
  476 + toast.error("Failed to delete menu.", {
  477 + description: e?.message ? String(e.message) : "Please try again.",
  478 + });
  479 + } finally {
  480 + setSubmitting(false);
  481 + }
  482 + };
  483 +
  484 + return (
  485 + <Dialog open={open} onOpenChange={onOpenChange}>
  486 + <DialogContent className="sm:max-w-none" style={{ width: "30%" }}>
  487 + <DialogHeader>
  488 + <DialogTitle>Delete Menu</DialogTitle>
  489 + <DialogDescription>This action cannot be undone.</DialogDescription>
  490 + </DialogHeader>
  491 +
  492 + <div className="text-sm text-gray-700">
  493 + Are you sure you want to delete <span className="font-medium">{name}</span>?
  494 + </div>
  495 +
  496 + <DialogFooter className="flex-row flex-wrap justify-end">
  497 + <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}>
  498 + Cancel
  499 + </Button>
  500 + <Button className="min-w-24" variant="destructive" disabled={submitting} onClick={submit}>
  501 + {submitting ? "Deleting..." : "Delete"}
  502 + </Button>
  503 + </DialogFooter>
  504 + </DialogContent>
  505 + </Dialog>
  506 + );
  507 +}
  508 +
... ...
美国版/Food Labeling Management Platform/src/components/people/PeopleView.tsx
1   -import React, { useState } from 'react';
  1 +import React, { useEffect, useMemo, useRef, useState } from "react";
2 2 import {
3 3 Search,
4 4 Plus,
... ... @@ -6,13 +6,16 @@ import {
6 6 Upload,
7 7 Edit,
8 8 MoreHorizontal,
  9 + ChevronDown,
  10 + ChevronRight,
  11 + Trash2,
9 12 FileText,
10 13 MapPin,
11 14 Shield,
12 15 Bell,
13 16 Check,
14 17 X
15   -} from 'lucide-react';
  18 +} from "lucide-react";
16 19 import { Button } from "../ui/button";
17 20 import { Input } from "../ui/input";
18 21 import {
... ... @@ -39,12 +42,28 @@ import {
39 42 SelectTrigger,
40 43 SelectValue,
41 44 } from "../ui/select";
  45 +import {
  46 + Pagination,
  47 + PaginationContent,
  48 + PaginationItem,
  49 + PaginationLink,
  50 + PaginationNext,
  51 + PaginationPrevious,
  52 +} from "../ui/pagination";
42 53 import { Label } from "../ui/label";
43 54 import { Switch } from "../ui/switch";
44 55 import { Badge } from "../ui/badge";
45 56 import { Checkbox } from "../ui/checkbox";
46 57 import { ScrollArea } from "../ui/scroll-area";
47 58 import { cn } from "../ui/utils";
  59 +import { toast } from "sonner";
  60 +
  61 +import { getRbacMenuTree } from "../../services/systemMenuService";
  62 +import { deleteRoleMenus, getRoleMenuIds, setRoleMenus } from "../../services/rbacRoleMenuService";
  63 +import type { RbacMenuTreeNode } from "../../types/systemMenu";
  64 +import { getRoles } from "../../services/roleService";
  65 +import { createRbacRole, deleteRbacRole, updateRbacRole } from "../../services/rbacRoleService";
  66 +import type { RoleDto } from "../../types/role";
48 67  
49 68 // --- Mock Data ---
50 69  
... ... @@ -129,13 +148,26 @@ export function PeopleView() {
129 148 const [activeTab, setActiveTab] = useState<ViewTab>('Roles');
130 149  
131 150 // Data States
132   - const [roles, setRoles] = useState(MOCK_ROLES);
  151 + const [roles, setRoles] = useState<RoleDto[]>([]);
  152 + const [roleTotal, setRoleTotal] = useState(0);
  153 + const [rolesLoading, setRolesLoading] = useState(false);
  154 + const [roleRefreshSeq, setRoleRefreshSeq] = useState(0);
  155 + const [rolePageIndex, setRolePageIndex] = useState(1);
  156 + const [rolePageSize, setRolePageSize] = useState(10);
  157 + const roleTotalPages = Math.max(1, Math.ceil(roleTotal / rolePageSize));
  158 + const rolesAbortRef = useRef<AbortController | null>(null);
  159 + const [roleKeyword, setRoleKeyword] = useState("");
  160 + const roleKeywordTimerRef = useRef<number | null>(null);
  161 + const [debouncedRoleKeyword, setDebouncedRoleKeyword] = useState("");
133 162 const [partners, setPartners] = useState(MOCK_PARTNERS);
134 163 const [groups, setGroups] = useState(MOCK_GROUPS);
135 164 const [members, setMembers] = useState(MOCK_MEMBERS);
136 165  
137 166 // Dialog States
138 167 const [isRoleDialogOpen, setIsRoleDialogOpen] = useState(false);
  168 + const [editingRole, setEditingRole] = useState<RoleDto | null>(null);
  169 + const [isRoleMenuDialogOpen, setIsRoleMenuDialogOpen] = useState(false);
  170 + const [menuRole, setMenuRole] = useState<RoleDto | null>(null);
139 171 const [isPartnerDialogOpen, setIsPartnerDialogOpen] = useState(false);
140 172 const [isGroupDialogOpen, setIsGroupDialogOpen] = useState(false);
141 173 const [isMemberDialogOpen, setIsMemberDialogOpen] = useState(false);
... ... @@ -145,9 +177,59 @@ export function PeopleView() {
145 177 alert(`Exporting ${activeTab} list to PDF...`);
146 178 };
147 179  
  180 + useEffect(() => {
  181 + if (roleKeywordTimerRef.current) window.clearTimeout(roleKeywordTimerRef.current);
  182 + roleKeywordTimerRef.current = window.setTimeout(() => setDebouncedRoleKeyword(roleKeyword.trim()), 300);
  183 + return () => {
  184 + if (roleKeywordTimerRef.current) window.clearTimeout(roleKeywordTimerRef.current);
  185 + };
  186 + }, [roleKeyword]);
  187 +
  188 + useEffect(() => {
  189 + setRolePageIndex(1);
  190 + }, [debouncedRoleKeyword, rolePageSize]);
  191 +
  192 + useEffect(() => {
  193 + if (activeTab !== "Roles") return;
  194 + const run = async () => {
  195 + rolesAbortRef.current?.abort();
  196 + const ac = new AbortController();
  197 + rolesAbortRef.current = ac;
  198 +
  199 + setRolesLoading(true);
  200 + try {
  201 + const res = await getRoles(
  202 + {
  203 + skipCount: Math.max(1, rolePageIndex),
  204 + maxResultCount: rolePageSize,
  205 + roleName: debouncedRoleKeyword || undefined,
  206 + },
  207 + ac.signal,
  208 + );
  209 + setRoles(res.items ?? []);
  210 + setRoleTotal(res.totalCount ?? 0);
  211 + } catch (e: any) {
  212 + if (e?.name === "AbortError") return;
  213 + toast.error("Failed to load roles.", {
  214 + description: e?.message ? String(e.message) : "Please try again.",
  215 + });
  216 + setRoles([]);
  217 + setRoleTotal(0);
  218 + } finally {
  219 + setRolesLoading(false);
  220 + }
  221 + };
  222 +
  223 + run();
  224 + return () => rolesAbortRef.current?.abort();
  225 + }, [activeTab, debouncedRoleKeyword, rolePageIndex, rolePageSize, roleRefreshSeq]);
  226 +
148 227 const openCreateDialog = () => {
149 228 switch (activeTab) {
150   - case 'Roles': setIsRoleDialogOpen(true); break;
  229 + case 'Roles':
  230 + setEditingRole(null);
  231 + setIsRoleDialogOpen(true);
  232 + break;
151 233 case 'Partner': setIsPartnerDialogOpen(true); break;
152 234 case 'Group': setIsGroupDialogOpen(true); break;
153 235 case 'Team Member': setIsMemberDialogOpen(true); break;
... ... @@ -163,6 +245,10 @@ export function PeopleView() {
163 245 <div className="flex flex-nowrap items-center gap-3">
164 246 <Input
165 247 placeholder="Search"
  248 + value={activeTab === "Roles" ? roleKeyword : ""}
  249 + onChange={(e) => {
  250 + if (activeTab === "Roles") setRoleKeyword(e.target.value);
  251 + }}
166 252 style={{ height: 40, boxSizing: 'border-box' }}
167 253 className="border border-gray-300 rounded-md w-40 shrink-0 bg-white placeholder:text-gray-500"
168 254 />
... ... @@ -216,40 +302,140 @@ export function PeopleView() {
216 302 switch (activeTab) {
217 303 case 'Roles':
218 304 return (
219   - <Table>
220   - <TableHeader>
221   - <TableRow className="bg-gray-100">
222   - <TableHead className="font-bold text-black border-r">Role Name</TableHead>
223   - <TableHead className="font-bold text-black border-r">Access Permissions</TableHead>
224   - <TableHead className="font-bold text-black border-r">Notifications</TableHead>
225   - <TableHead className="font-bold text-black text-center">Actions</TableHead>
226   - </TableRow>
227   - </TableHeader>
228   - <TableBody>
229   - {roles.map(role => (
230   - <TableRow key={role.id}>
231   - <TableCell className="font-medium border-r">{role.name}</TableCell>
232   - <TableCell className="border-r">
233   - <div className="flex flex-wrap gap-1">
234   - {role.permissions.map(p => (
235   - <Badge key={p} variant="secondary" className="text-xs bg-blue-100 text-blue-800 hover:bg-blue-100">{p}</Badge>
236   - ))}
237   - </div>
238   - </TableCell>
239   - <TableCell className="border-r">
240   - <div className="flex flex-wrap gap-1">
241   - {role.notifications.map(n => (
242   - <Badge key={n} variant="outline" className="text-xs border-orange-200 text-orange-700 bg-orange-50">{n}</Badge>
243   - ))}
244   - </div>
245   - </TableCell>
246   - <TableCell className="text-center">
247   - <Button variant="ghost" size="sm"><Edit className="w-4 h-4 text-gray-500" /></Button>
248   - </TableCell>
  305 + <div className="flex flex-col">
  306 + <Table>
  307 + <TableHeader>
  308 + <TableRow className="bg-gray-100">
  309 + <TableHead className="font-bold text-black border-r">Role Name</TableHead>
  310 + <TableHead className="font-bold text-black border-r">Role Code</TableHead>
  311 + <TableHead className="font-bold text-black border-r">Status</TableHead>
  312 + <TableHead className="font-bold text-black border-r">Order</TableHead>
  313 + <TableHead className="font-bold text-black text-center">Actions</TableHead>
249 314 </TableRow>
250   - ))}
251   - </TableBody>
252   - </Table>
  315 + </TableHeader>
  316 + <TableBody>
  317 + {roles.length === 0 ? (
  318 + <TableRow>
  319 + <TableCell colSpan={5} className="text-center text-sm text-gray-500 py-10">
  320 + {rolesLoading ? "Loading..." : "No data"}
  321 + </TableCell>
  322 + </TableRow>
  323 + ) : (
  324 + roles.map((r) => (
  325 + <TableRow key={r.id}>
  326 + <TableCell className="font-medium border-r">{r.roleName ?? "N/A"}</TableCell>
  327 + <TableCell className="border-r text-gray-600">{r.roleCode ?? "N/A"}</TableCell>
  328 + <TableCell className="border-r">
  329 + <Badge className={r.state ? "bg-green-600" : "bg-gray-400"}>
  330 + {r.state ? "Active" : "Inactive"}
  331 + </Badge>
  332 + </TableCell>
  333 + <TableCell className="border-r text-gray-600">{r.orderNum ?? "N/A"}</TableCell>
  334 + <TableCell className="text-center">
  335 + <Button
  336 + variant="ghost"
  337 + size="sm"
  338 + onClick={() => {
  339 + setMenuRole(r);
  340 + setIsRoleMenuDialogOpen(true);
  341 + }}
  342 + title="Menu Permissions"
  343 + >
  344 + <Shield className="w-4 h-4 text-blue-600" />
  345 + </Button>
  346 + <Button
  347 + variant="ghost"
  348 + size="sm"
  349 + onClick={() => {
  350 + setEditingRole(r);
  351 + setIsRoleDialogOpen(true);
  352 + }}
  353 + >
  354 + <Edit className="w-4 h-4 text-gray-500" />
  355 + </Button>
  356 + <Button
  357 + variant="ghost"
  358 + size="sm"
  359 + onClick={async () => {
  360 + const ok = window.confirm(`Delete role "${r.roleName ?? r.id}"? This cannot be undone.`);
  361 + if (!ok) return;
  362 + try {
  363 + await deleteRbacRole(r.id);
  364 + toast.success("Role deleted.", { description: "The role has been removed successfully." });
  365 + setRoleRefreshSeq((x) => x + 1);
  366 + } catch (e: any) {
  367 + toast.error("Failed to delete role.", {
  368 + description: e?.message ? String(e.message) : "Please try again.",
  369 + });
  370 + }
  371 + }}
  372 + title="Delete role"
  373 + >
  374 + <Trash2 className="w-4 h-4 text-red-600" />
  375 + </Button>
  376 + </TableCell>
  377 + </TableRow>
  378 + ))
  379 + )}
  380 + </TableBody>
  381 + </Table>
  382 +
  383 + <div className="px-4 py-3 border-t border-gray-200 bg-white flex flex-wrap items-center justify-between gap-3">
  384 + <div className="text-sm text-gray-600">
  385 + Showing {roleTotal === 0 ? 0 : (rolePageIndex - 1) * rolePageSize + 1}-
  386 + {Math.min(rolePageIndex * rolePageSize, roleTotal)} of {roleTotal}
  387 + </div>
  388 +
  389 + <div className="flex items-center gap-3">
  390 + <Select value={String(rolePageSize)} onValueChange={(v) => setRolePageSize(Number(v))}>
  391 + <SelectTrigger className="w-[110px] h-9 rounded-md border border-gray-300 bg-white text-gray-900">
  392 + <SelectValue />
  393 + </SelectTrigger>
  394 + <SelectContent>
  395 + {[10, 20, 50].map((n) => (
  396 + <SelectItem key={n} value={String(n)}>
  397 + {n} / page
  398 + </SelectItem>
  399 + ))}
  400 + </SelectContent>
  401 + </Select>
  402 +
  403 + <Pagination className="mx-0 w-auto justify-end">
  404 + <PaginationContent>
  405 + <PaginationItem>
  406 + <PaginationPrevious
  407 + href="#"
  408 + size="default"
  409 + onClick={(e) => {
  410 + e.preventDefault();
  411 + setRolePageIndex((p) => Math.max(1, p - 1));
  412 + }}
  413 + aria-disabled={rolePageIndex <= 1}
  414 + className={rolePageIndex <= 1 ? "pointer-events-none opacity-50" : ""}
  415 + />
  416 + </PaginationItem>
  417 + <PaginationItem>
  418 + <PaginationLink href="#" isActive size="default" onClick={(e) => e.preventDefault()}>
  419 + Page {rolePageIndex} / {roleTotalPages}
  420 + </PaginationLink>
  421 + </PaginationItem>
  422 + <PaginationItem>
  423 + <PaginationNext
  424 + href="#"
  425 + size="default"
  426 + onClick={(e) => {
  427 + e.preventDefault();
  428 + setRolePageIndex((p) => Math.min(roleTotalPages, p + 1));
  429 + }}
  430 + aria-disabled={rolePageIndex >= roleTotalPages}
  431 + className={rolePageIndex >= roleTotalPages ? "pointer-events-none opacity-50" : ""}
  432 + />
  433 + </PaginationItem>
  434 + </PaginationContent>
  435 + </Pagination>
  436 + </div>
  437 + </div>
  438 + </div>
253 439 );
254 440  
255 441 case 'Partner':
... ... @@ -373,7 +559,26 @@ export function PeopleView() {
373 559 </div>
374 560  
375 561 {/* --- Dialogs --- */}
376   - <CreateRoleDialog open={isRoleDialogOpen} onOpenChange={setIsRoleDialogOpen} />
  562 + <RoleDialog
  563 + open={isRoleDialogOpen}
  564 + role={editingRole}
  565 + onOpenChange={(open) => {
  566 + setIsRoleDialogOpen(open);
  567 + if (!open) setEditingRole(null);
  568 + }}
  569 + onSaved={() => {
  570 + setRolePageIndex(1);
  571 + setRoleRefreshSeq((x) => x + 1);
  572 + }}
  573 + />
  574 + <RoleMenuPermissionsDialog
  575 + open={isRoleMenuDialogOpen}
  576 + role={menuRole}
  577 + onOpenChange={(open) => {
  578 + setIsRoleMenuDialogOpen(open);
  579 + if (!open) setMenuRole(null);
  580 + }}
  581 + />
377 582 <CreatePartnerDialog open={isPartnerDialogOpen} onOpenChange={setIsPartnerDialogOpen} />
378 583 <CreateGroupDialog open={isGroupDialogOpen} onOpenChange={setIsGroupDialogOpen} />
379 584 <CreateMemberDialog open={isMemberDialogOpen} onOpenChange={setIsMemberDialogOpen} roles={roles} />
... ... @@ -383,53 +588,414 @@ export function PeopleView() {
383 588  
384 589 // --- Sub-Components (Dialogs) ---
385 590  
386   -function CreateRoleDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
  591 +function RoleDialog({
  592 + open,
  593 + role,
  594 + onOpenChange,
  595 + onSaved,
  596 +}: {
  597 + open: boolean;
  598 + role: RoleDto | null;
  599 + onOpenChange: (open: boolean) => void;
  600 + onSaved: () => void;
  601 +}) {
  602 + const isEdit = !!role?.id;
  603 + const [submitting, setSubmitting] = useState(false);
  604 + const [roleName, setRoleName] = useState("");
  605 + const [roleCode, setRoleCode] = useState("");
  606 + const [remark, setRemark] = useState("");
  607 + const [orderNum, setOrderNum] = useState("");
  608 + const [state, setState] = useState(true);
  609 +
  610 + useEffect(() => {
  611 + if (!open) return;
  612 + setSubmitting(false);
  613 + setRoleName(role?.roleName ?? "");
  614 + setRoleCode(role?.roleCode ?? "");
  615 + setRemark(role?.remark ?? "");
  616 + setOrderNum(role?.orderNum === null || role?.orderNum === undefined ? "" : String(role.orderNum));
  617 + setState(role?.state ?? true);
  618 + }, [open, role]);
  619 +
  620 + const canSubmit = useMemo(() => {
  621 + return Boolean(roleName.trim() && roleCode.trim());
  622 + }, [roleName, roleCode]);
  623 +
  624 + const toIntOrNullLocal = (v: string): number | null => {
  625 + const s = v.trim();
  626 + if (!s) return null;
  627 + const n = Number.parseInt(s, 10);
  628 + return Number.isFinite(n) ? n : null;
  629 + };
  630 +
  631 + const submit = async () => {
  632 + if (!canSubmit) {
  633 + toast.error("Please fill in required fields.", {
  634 + description: "Role Name and Role Code are required.",
  635 + });
  636 + return;
  637 + }
  638 + setSubmitting(true);
  639 + try {
  640 + const payload = {
  641 + roleName: roleName.trim(),
  642 + roleCode: roleCode.trim(),
  643 + remark: remark.trim() ? remark.trim() : null,
  644 + state: !!state,
  645 + orderNum: toIntOrNullLocal(orderNum),
  646 + };
  647 + if (isEdit && role?.id) {
  648 + await updateRbacRole(role.id, payload);
  649 + toast.success("Role updated.", { description: "Role fields have been saved successfully." });
  650 + } else {
  651 + await createRbacRole(payload);
  652 + toast.success("Role created.", { description: "A new role has been created successfully." });
  653 + }
  654 + onOpenChange(false);
  655 + onSaved();
  656 + } catch (e: any) {
  657 + toast.error(isEdit ? "Failed to update role." : "Failed to create role.", {
  658 + description: e?.message ? String(e.message) : "Please try again.",
  659 + });
  660 + } finally {
  661 + setSubmitting(false);
  662 + }
  663 + };
  664 +
387 665 return (
388 666 <Dialog open={open} onOpenChange={onOpenChange}>
389 667 <DialogContent className="sm:max-w-[600px]">
390 668 <DialogHeader>
391   - <DialogTitle>Create New Role</DialogTitle>
392   - <DialogDescription>Define permissions and notification settings for this role.</DialogDescription>
  669 + <DialogTitle>{isEdit ? "Edit Role" : "Create Role"}</DialogTitle>
  670 + <DialogDescription>
  671 + {isEdit ? "Update role fields and save changes." : "Fill out the form to create a new role."}
  672 + </DialogDescription>
393 673 </DialogHeader>
394 674 <div className="space-y-4 py-4">
395 675 <div className="space-y-2">
396   - <Label>Role Name</Label>
397   - <Input placeholder="e.g. Inventory Specialist" />
  676 + <Label>Role Name *</Label>
  677 + <Input value={roleName} onChange={(e) => setRoleName(e.target.value)} placeholder="e.g. Inventory Specialist" />
398 678 </div>
399   -
400   - <div className="space-y-3">
401   - <Label className="flex items-center gap-2"><Shield className="w-4 h-4" /> Access Permissions</Label>
402   - <div className="grid grid-cols-2 gap-2 p-3 bg-gray-50 rounded border border-gray-100">
403   - {['Manage Labels', 'Manage Products', 'Manage People', 'View Reports', 'Edit Settings', 'Approve Batches'].map(perm => (
404   - <div key={perm} className="flex items-center space-x-2">
405   - <Checkbox id={`perm-${perm}`} />
406   - <label htmlFor={`perm-${perm}`} className="text-sm font-medium leading-none cursor-pointer">{perm}</label>
407   - </div>
408   - ))}
409   - </div>
  679 +
  680 + <div className="space-y-2">
  681 + <Label>Role Code *</Label>
  682 + <Input value={roleCode} onChange={(e) => setRoleCode(e.target.value)} placeholder="e.g. inventory_specialist" />
410 683 </div>
411 684  
412   - <div className="space-y-3">
413   - <Label className="flex items-center gap-2"><Bell className="w-4 h-4" /> Notifications (Alerts)</Label>
414   - <div className="grid grid-cols-1 gap-2 p-3 bg-gray-50 rounded border border-gray-100">
415   - <div className="flex items-center space-x-2">
416   - <Checkbox id="notif-expiry" defaultChecked />
417   - <label htmlFor="notif-expiry" className="text-sm font-medium leading-none cursor-pointer">Label Expiry Alerts</label>
418   - </div>
419   - <div className="flex items-center space-x-2">
420   - <Checkbox id="notif-stock" />
421   - <label htmlFor="notif-stock" className="text-sm font-medium leading-none cursor-pointer">Low Stock Alerts</label>
422   - </div>
423   - <div className="flex items-center space-x-2">
424   - <Checkbox id="notif-tasks" />
425   - <label htmlFor="notif-tasks" className="text-sm font-medium leading-none cursor-pointer">New Task Assignments</label>
426   - </div>
  685 + <div className="space-y-2">
  686 + <Label>Remark</Label>
  687 + <Input value={remark} onChange={(e) => setRemark(e.target.value)} placeholder="Optional" />
  688 + </div>
  689 +
  690 + <div className="grid grid-cols-2 gap-4">
  691 + <div className="space-y-2">
  692 + <Label>Order</Label>
  693 + <Input value={orderNum} onChange={(e) => setOrderNum(e.target.value)} placeholder="e.g. 10" />
  694 + </div>
  695 + <div className="flex items-center justify-between border border-gray-200 rounded-md px-3 bg-white" style={{ height: 40 }}>
  696 + <div className="text-sm font-medium text-gray-900">Enabled</div>
  697 + <Switch checked={state} onCheckedChange={setState} />
427 698 </div>
428 699 </div>
429 700 </div>
430 701 <DialogFooter>
431 702 <Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
432   - <Button onClick={() => onOpenChange(false)} className="bg-blue-600 text-white hover:bg-blue-700">Save Role</Button>
  703 + <Button
  704 + disabled={submitting}
  705 + onClick={submit}
  706 + className="bg-blue-600 text-white hover:bg-blue-700"
  707 + >
  708 + {submitting ? "Saving..." : "Save"}
  709 + </Button>
  710 + </DialogFooter>
  711 + </DialogContent>
  712 + </Dialog>
  713 + );
  714 +}
  715 +
  716 +function RoleMenuPermissionsDialog({
  717 + open,
  718 + role,
  719 + onOpenChange,
  720 +}: {
  721 + open: boolean;
  722 + role: RoleDto | null;
  723 + onOpenChange: (open: boolean) => void;
  724 +}) {
  725 + const roleId = role?.id ?? "";
  726 + const roleName = role?.roleName ?? "";
  727 + const [submitting, setSubmitting] = useState(false);
  728 +
  729 + const [menuTree, setMenuTree] = useState<RbacMenuTreeNode[]>([]);
  730 + const [menuExpandedIds, setMenuExpandedIds] = useState<Set<string>>(new Set());
  731 + const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
  732 + const [loadingMenus, setLoadingMenus] = useState(false);
  733 + const abortRef = useRef<AbortController | null>(null);
  734 +
  735 + const [menuKeyword, setMenuKeyword] = useState("");
  736 + const menuKeywordTimerRef = useRef<number | null>(null);
  737 + const [debouncedMenuKeyword, setDebouncedMenuKeyword] = useState("");
  738 +
  739 + useEffect(() => {
  740 + if (menuKeywordTimerRef.current) window.clearTimeout(menuKeywordTimerRef.current);
  741 + menuKeywordTimerRef.current = window.setTimeout(() => setDebouncedMenuKeyword(menuKeyword.trim()), 300);
  742 + return () => {
  743 + if (menuKeywordTimerRef.current) window.clearTimeout(menuKeywordTimerRef.current);
  744 + };
  745 + }, [menuKeyword]);
  746 +
  747 + useEffect(() => {
  748 + if (!open) return;
  749 + setSubmitting(false);
  750 + setSelectedIds(new Set());
  751 + setMenuExpandedIds(new Set());
  752 +
  753 + const run = async () => {
  754 + abortRef.current?.abort();
  755 + const ac = new AbortController();
  756 + abortRef.current = ac;
  757 + setLoadingMenus(true);
  758 + try {
  759 + const tree = await getRbacMenuTree(ac.signal);
  760 + setMenuTree(tree ?? []);
  761 + if (roleId) {
  762 + const checked = await getRoleMenuIds(roleId);
  763 + setSelectedIds(new Set(checked));
  764 + }
  765 + } catch (e: any) {
  766 + if (e?.name === "AbortError") return;
  767 + toast.error("Failed to load menus.", { description: e?.message ? String(e.message) : "Please try again." });
  768 + setMenuTree([]);
  769 + setSelectedIds(new Set());
  770 + } finally {
  771 + setLoadingMenus(false);
  772 + }
  773 + };
  774 +
  775 + run();
  776 + return () => abortRef.current?.abort();
  777 + }, [open, roleId]);
  778 +
  779 + const menuTotal = useMemo(() => {
  780 + const walk = (nodes: RbacMenuTreeNode[]): number =>
  781 + nodes.reduce((acc, n) => acc + 1 + (n.children ? walk(n.children) : 0), 0);
  782 + return walk(menuTree);
  783 + }, [menuTree]);
  784 +
  785 + const filterTree = useMemo(() => {
  786 + const kw = debouncedMenuKeyword.trim().toLowerCase();
  787 + if (!kw) return menuTree;
  788 + const match = (n: RbacMenuTreeNode) => {
  789 + const name = (n.menuName ?? "").toLowerCase();
  790 + const url = (n.routeUrl ?? "").toLowerCase();
  791 + return name.includes(kw) || url.includes(kw);
  792 + };
  793 + const recur = (nodes: RbacMenuTreeNode[]): RbacMenuTreeNode[] => {
  794 + const out: RbacMenuTreeNode[] = [];
  795 + for (const n of nodes) {
  796 + const children = n.children ? recur(n.children) : [];
  797 + if (match(n) || children.length) out.push({ ...n, children: children.length ? children : undefined });
  798 + }
  799 + return out;
  800 + };
  801 + return recur(menuTree);
  802 + }, [menuTree, debouncedMenuKeyword]);
  803 +
  804 + useEffect(() => {
  805 + const kw = debouncedMenuKeyword.trim();
  806 + if (!kw) return;
  807 + const next = new Set<string>();
  808 + const walk = (nodes: RbacMenuTreeNode[]) => {
  809 + for (const n of nodes) {
  810 + if (n.children?.length) next.add(n.id);
  811 + if (n.children?.length) walk(n.children);
  812 + }
  813 + };
  814 + walk(filterTree);
  815 + setMenuExpandedIds(next);
  816 + }, [debouncedMenuKeyword, filterTree]);
  817 +
  818 + const getNodeAllIds = (node: RbacMenuTreeNode): string[] => {
  819 + const ids: string[] = [];
  820 + const walk = (n: RbacMenuTreeNode) => {
  821 + if (n.id) ids.push(n.id);
  822 + if (n.children?.length) n.children.forEach(walk);
  823 + };
  824 + walk(node);
  825 + return ids;
  826 + };
  827 +
  828 + const isCheckedState = (node: RbacMenuTreeNode): { checked: boolean; indeterminate: boolean } => {
  829 + const ids = getNodeAllIds(node);
  830 + if (!ids.length) return { checked: false, indeterminate: false };
  831 + let hit = 0;
  832 + for (const id of ids) if (selectedIds.has(id)) hit += 1;
  833 + if (hit === 0) return { checked: false, indeterminate: false };
  834 + if (hit === ids.length) return { checked: true, indeterminate: false };
  835 + return { checked: false, indeterminate: true };
  836 + };
  837 +
  838 + const toggleNode = (node: RbacMenuTreeNode, checked: boolean) => {
  839 + setSelectedIds((prev) => {
  840 + const next = new Set(prev);
  841 + const ids = getNodeAllIds(node);
  842 + if (checked) ids.forEach((id) => next.add(id));
  843 + else ids.forEach((id) => next.delete(id));
  844 + return next;
  845 + });
  846 + };
  847 +
  848 + const toggleExpanded = (id: string) => {
  849 + setMenuExpandedIds((prev) => {
  850 + const next = new Set(prev);
  851 + if (next.has(id)) next.delete(id);
  852 + else next.add(id);
  853 + return next;
  854 + });
  855 + };
  856 +
  857 + const highlight = (text: string | null | undefined) => {
  858 + const kw = debouncedMenuKeyword.trim();
  859 + const t = text ?? "";
  860 + if (!kw) return t || "N/A";
  861 + const idx = t.toLowerCase().indexOf(kw.toLowerCase());
  862 + if (idx < 0) return t || "N/A";
  863 + const a = t.slice(0, idx);
  864 + const b = t.slice(idx, idx + kw.length);
  865 + const c = t.slice(idx + kw.length);
  866 + return (
  867 + <span>
  868 + {a}
  869 + <span className="bg-yellow-200 rounded px-0.5">{b}</span>
  870 + {c}
  871 + </span>
  872 + );
  873 + };
  874 +
  875 + const TreeNodeRow = ({ node, depth }: { node: RbacMenuTreeNode; depth: number }) => {
  876 + const hasChildren = !!node.children?.length;
  877 + const expanded = menuExpandedIds.has(node.id);
  878 + const { checked, indeterminate } = isCheckedState(node);
  879 + return (
  880 + <div>
  881 + <div className="flex items-center gap-2 py-1" style={{ paddingLeft: depth * 16 }}>
  882 + <button
  883 + type="button"
  884 + className={cn(
  885 + "h-6 w-6 flex items-center justify-center rounded hover:bg-gray-100",
  886 + !hasChildren && "opacity-0 pointer-events-none",
  887 + )}
  888 + onClick={() => hasChildren && toggleExpanded(node.id)}
  889 + aria-label={hasChildren ? (expanded ? "Collapse" : "Expand") : "No children"}
  890 + >
  891 + {hasChildren ? (expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />) : null}
  892 + </button>
  893 + <Checkbox
  894 + id={`perm-menu-${node.id}`}
  895 + checked={indeterminate ? "indeterminate" : checked}
  896 + onCheckedChange={(v) => toggleNode(node, !!v)}
  897 + />
  898 + <label htmlFor={`perm-menu-${node.id}`} className="text-sm leading-none cursor-pointer select-none">
  899 + {highlight(node.menuName ?? node.routeUrl ?? node.id)}
  900 + </label>
  901 + </div>
  902 + {hasChildren && expanded && (
  903 + <div>
  904 + {node.children!.map((c) => (
  905 + <TreeNodeRow key={c.id} node={c} depth={depth + 1} />
  906 + ))}
  907 + </div>
  908 + )}
  909 + </div>
  910 + );
  911 + };
  912 +
  913 + const submit = async () => {
  914 + if (!roleId) return;
  915 + setSubmitting(true);
  916 + try {
  917 + await setRoleMenus({
  918 + roleId,
  919 + menuIds: Array.from(selectedIds),
  920 + });
  921 + toast.success("Role menu permissions saved.", {
  922 + description: "Menu permissions have been updated successfully.",
  923 + });
  924 + onOpenChange(false);
  925 + } catch (e: any) {
  926 + toast.error("Failed to save menu permissions.", {
  927 + description: e?.message ? String(e.message) : "Please try again.",
  928 + });
  929 + } finally {
  930 + setSubmitting(false);
  931 + }
  932 + };
  933 +
  934 + const clearAll = async () => {
  935 + if (!roleId || selectedIds.size === 0) return;
  936 + setSubmitting(true);
  937 + try {
  938 + await deleteRoleMenus({
  939 + roleId,
  940 + menuIds: Array.from(selectedIds),
  941 + });
  942 + setSelectedIds(new Set());
  943 + toast.success("Role menu permissions cleared.", {
  944 + description: "Selected permissions have been removed.",
  945 + });
  946 + } catch (e: any) {
  947 + toast.error("Failed to delete menu permissions.", {
  948 + description: e?.message ? String(e.message) : "Please try again.",
  949 + });
  950 + } finally {
  951 + setSubmitting(false);
  952 + }
  953 + };
  954 +
  955 + return (
  956 + <Dialog open={open} onOpenChange={onOpenChange}>
  957 + <DialogContent className="sm:max-w-none" style={{ width: "50%" }}>
  958 + <DialogHeader>
  959 + <DialogTitle>Menu Permissions</DialogTitle>
  960 + <DialogDescription>
  961 + {roleName ? `Set menu permissions for role: ${roleName}` : "Set menu permissions for this role."}
  962 + </DialogDescription>
  963 + </DialogHeader>
  964 + <div className="space-y-4 py-4">
  965 + <div className="rounded border border-gray-200 bg-white">
  966 + <div className="px-3 py-2 text-xs text-gray-500 border-b border-gray-200">
  967 + <div className="flex items-center gap-2 justify-between">
  968 + <div>{loadingMenus ? "Loading menus..." : `Total ${menuTotal} menus`}</div>
  969 + <Input
  970 + value={menuKeyword}
  971 + onChange={(e) => setMenuKeyword(e.target.value)}
  972 + placeholder="Search menus"
  973 + className="h-8 w-44 bg-white"
  974 + />
  975 + </div>
  976 + </div>
  977 + <ScrollArea className="h-72">
  978 + <div className="p-3 space-y-2">
  979 + {filterTree.map((n) => (
  980 + <TreeNodeRow key={n.id} node={n} depth={0} />
  981 + ))}
  982 + {!loadingMenus && filterTree.length === 0 && (
  983 + <div className="text-sm text-gray-500 py-6 text-center">No menus.</div>
  984 + )}
  985 + </div>
  986 + </ScrollArea>
  987 + </div>
  988 + </div>
  989 + <DialogFooter className="flex flex-row justify-end gap-2">
  990 + <Button variant="outline" onClick={() => onOpenChange(false)}>
  991 + Cancel
  992 + </Button>
  993 + <Button variant="destructive" disabled={submitting || selectedIds.size === 0 || !roleId} onClick={clearAll}>
  994 + Delete Selected
  995 + </Button>
  996 + <Button disabled={submitting || !roleId} onClick={submit} className="bg-blue-600 text-white hover:bg-blue-700">
  997 + {submitting ? "Saving..." : "Save"}
  998 + </Button>
433 999 </DialogFooter>
434 1000 </DialogContent>
435 1001 </Dialog>
... ...
美国版/Food Labeling Management Platform/src/components/system-menu/SystemMenuView.tsx 0 → 100644
  1 +import React, { useEffect, useMemo, useRef, useState } from "react";
  2 +import {
  3 + Edit,
  4 + FileBox,
  5 + FileText,
  6 + HelpCircle,
  7 + Layers,
  8 + LayoutDashboard,
  9 + MapPin,
  10 + MoreHorizontal,
  11 + Package,
  12 + Plus,
  13 + Settings,
  14 + Tag,
  15 + Trash2,
  16 + Type,
  17 + Users,
  18 +} from "lucide-react";
  19 +import { toast } from "sonner";
  20 +
  21 +import { Button } from "../ui/button";
  22 +import { Input } from "../ui/input";
  23 +import { Label } from "../ui/label";
  24 +import { Switch } from "../ui/switch";
  25 +import { Textarea } from "../ui/textarea";
  26 +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
  27 +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
  28 +import {
  29 + Dialog,
  30 + DialogContent,
  31 + DialogDescription,
  32 + DialogFooter,
  33 + DialogHeader,
  34 + DialogTitle,
  35 +} from "../ui/dialog";
  36 +import {
  37 + Table,
  38 + TableBody,
  39 + TableCell,
  40 + TableHead,
  41 + TableHeader,
  42 + TableRow,
  43 +} from "../ui/table";
  44 +
  45 +import {
  46 + createSystemMenu,
  47 + deleteSystemMenu,
  48 + getDirectoryMenusForParentSelect,
  49 + getSystemMenus,
  50 + updateSystemMenu,
  51 +} from "../../services/systemMenuService";
  52 +import type { SystemMenuDto, SystemMenuUpsertInput } from "../../types/systemMenu";
  53 +
  54 +type IconKey =
  55 + | "Settings"
  56 + | "LayoutDashboard"
  57 + | "Tag"
  58 + | "MapPin"
  59 + | "Users"
  60 + | "Package"
  61 + | "FileText"
  62 + | "HelpCircle"
  63 + | "Layers"
  64 + | "Type"
  65 + | "FileBox";
  66 +
  67 +const ICONS: Record<IconKey, React.ComponentType<{ className?: string }>> = {
  68 + Settings,
  69 + LayoutDashboard,
  70 + Tag,
  71 + MapPin,
  72 + Users,
  73 + Package,
  74 + FileText,
  75 + HelpCircle,
  76 + Layers,
  77 + Type,
  78 + FileBox,
  79 +};
  80 +
  81 +function toDisplay(v: string | null | undefined): string {
  82 + const s = (v ?? "").trim();
  83 + return s ? s : "N/A";
  84 +}
  85 +
  86 +function toIntOrNull(v: string): number | null {
  87 + const s = v.trim();
  88 + if (!s) return null;
  89 + const n = Number.parseInt(s, 10);
  90 + return Number.isFinite(n) ? n : null;
  91 +}
  92 +
  93 +export function SystemMenuView() {
  94 + const [items, setItems] = useState<SystemMenuDto[]>([]);
  95 + const [loading, setLoading] = useState(false);
  96 + const [refreshSeq, setRefreshSeq] = useState(0);
  97 + const [actionsOpenForId, setActionsOpenForId] = useState<string | null>(null);
  98 +
  99 + const [keyword, setKeyword] = useState("");
  100 + const keywordTimerRef = useRef<number | null>(null);
  101 + const [debouncedKeyword, setDebouncedKeyword] = useState("");
  102 +
  103 + const [isCreateOpen, setIsCreateOpen] = useState(false);
  104 + const [isEditOpen, setIsEditOpen] = useState(false);
  105 + const [isDeleteOpen, setIsDeleteOpen] = useState(false);
  106 + const [editing, setEditing] = useState<SystemMenuDto | null>(null);
  107 + const [deleting, setDeleting] = useState<SystemMenuDto | null>(null);
  108 +
  109 + const abortRef = useRef<AbortController | null>(null);
  110 +
  111 + useEffect(() => {
  112 + if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current);
  113 + keywordTimerRef.current = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300);
  114 + return () => {
  115 + if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current);
  116 + };
  117 + }, [keyword]);
  118 +
  119 + useEffect(() => {
  120 + const run = async () => {
  121 + abortRef.current?.abort();
  122 + const ac = new AbortController();
  123 + abortRef.current = ac;
  124 +
  125 + setLoading(true);
  126 + try {
  127 + const res = await getSystemMenus(
  128 + {
  129 + // 这里不分页展示:一次性拉大页数据
  130 + skipCount: 1,
  131 + maxResultCount: 5000,
  132 + keyword: debouncedKeyword || undefined,
  133 + },
  134 + ac.signal,
  135 + );
  136 + setItems(res.items ?? []);
  137 + } catch (e: any) {
  138 + if (e?.name === "AbortError") return;
  139 + toast.error("Failed to load system menus.", {
  140 + description: e?.message ? String(e.message) : "Please try again.",
  141 + });
  142 + setItems([]);
  143 + } finally {
  144 + setLoading(false);
  145 + }
  146 + };
  147 +
  148 + run();
  149 + return () => abortRef.current?.abort();
  150 + }, [debouncedKeyword, refreshSeq]);
  151 +
  152 + const refreshList = () => setRefreshSeq((x) => x + 1);
  153 +
  154 + const openEdit = (m: SystemMenuDto) => {
  155 + setActionsOpenForId(null);
  156 + setEditing(m);
  157 + setIsEditOpen(true);
  158 + };
  159 +
  160 + const openDelete = (m: SystemMenuDto) => {
  161 + setActionsOpenForId(null);
  162 + setDeleting(m);
  163 + setIsDeleteOpen(true);
  164 + };
  165 +
  166 + return (
  167 + <div className="h-full flex flex-col">
  168 + <div className="pb-4">
  169 + <div className="flex flex-nowrap items-center gap-3">
  170 + <Input
  171 + placeholder="Search"
  172 + value={keyword}
  173 + onChange={(e) => setKeyword(e.target.value)}
  174 + style={{ height: 40, boxSizing: "border-box" }}
  175 + className="border border-gray-300 rounded-md w-40 shrink-0 bg-white placeholder:text-gray-500"
  176 + />
  177 + <div className="flex-1" />
  178 + <Button className="bg-blue-600 text-white hover:bg-blue-700" onClick={() => setIsCreateOpen(true)}>
  179 + <Plus className="w-4 h-4 mr-2" />
  180 + New Menu
  181 + </Button>
  182 + </div>
  183 + </div>
  184 +
  185 + {/* flex-col + min-h-0:表格区域滚动,底部分页栏始终可见(避免被 overflow-hidden 裁掉) */}
  186 + <div className="flex-1 flex flex-col min-h-0 bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
  187 + <div className="flex-1 min-h-0 overflow-auto">
  188 + <Table>
  189 + <TableHeader className="bg-gray-50 sticky top-0 z-10">
  190 + <TableRow className="hover:bg-gray-50">
  191 + <TableHead className="font-semibold text-gray-900">Menu Name</TableHead>
  192 + <TableHead className="font-semibold text-gray-900">Route URL</TableHead>
  193 + <TableHead className="font-semibold text-gray-900">Router Name</TableHead>
  194 + <TableHead className="font-semibold text-gray-900">Type</TableHead>
  195 + <TableHead className="font-semibold text-gray-900">Order</TableHead>
  196 + <TableHead className="font-semibold text-gray-900">Visible</TableHead>
  197 + <TableHead className="font-semibold text-gray-900">Enabled</TableHead>
  198 + <TableHead className="font-semibold text-gray-900 w-16 text-right">Actions</TableHead>
  199 + </TableRow>
  200 + </TableHeader>
  201 + <TableBody>
  202 + {items.length === 0 ? (
  203 + <TableRow>
  204 + <TableCell colSpan={8} className="text-center py-10 text-gray-500">
  205 + {loading ? "Loading..." : "No data"}
  206 + </TableCell>
  207 + </TableRow>
  208 + ) : (
  209 + items.map((m) => (
  210 + <TableRow key={m.id} className="hover:bg-gray-50">
  211 + <TableCell className="font-medium text-gray-900">{toDisplay(m.menuName)}</TableCell>
  212 + <TableCell className="text-gray-700">{toDisplay(m.routeUrl)}</TableCell>
  213 + <TableCell className="text-gray-700">{toDisplay(m.routerName)}</TableCell>
  214 + <TableCell className="text-gray-700">{m.menuType ?? "N/A"}</TableCell>
  215 + <TableCell className="text-gray-700">{m.orderNum ?? "N/A"}</TableCell>
  216 + <TableCell className="text-gray-700">{m.isShow ? "Yes" : "No"}</TableCell>
  217 + <TableCell className="text-gray-700">{m.state ? "Yes" : "No"}</TableCell>
  218 + <TableCell className="text-right">
  219 + <Popover
  220 + open={actionsOpenForId === m.id}
  221 + onOpenChange={(open) => setActionsOpenForId(open ? m.id : null)}
  222 + >
  223 + <PopoverTrigger asChild>
  224 + <Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Row actions">
  225 + <MoreHorizontal className="h-4 w-4" />
  226 + </Button>
  227 + </PopoverTrigger>
  228 + <PopoverContent className="w-44 p-2" align="end">
  229 + <div className="flex flex-col">
  230 + <Button variant="ghost" className="justify-start" onClick={() => openEdit(m)}>
  231 + <Edit className="w-4 h-4 mr-2" />
  232 + Edit
  233 + </Button>
  234 + <Button
  235 + variant="ghost"
  236 + className="justify-start text-red-600 hover:text-red-700"
  237 + onClick={() => openDelete(m)}
  238 + >
  239 + <Trash2 className="w-4 h-4 mr-2" />
  240 + Delete
  241 + </Button>
  242 + </div>
  243 + </PopoverContent>
  244 + </Popover>
  245 + </TableCell>
  246 + </TableRow>
  247 + ))
  248 + )}
  249 + </TableBody>
  250 + </Table>
  251 + </div>
  252 + </div>
  253 +
  254 + <SystemMenuDialog
  255 + mode="create"
  256 + open={isCreateOpen}
  257 + menu={null}
  258 + onOpenChange={setIsCreateOpen}
  259 + onSaved={refreshList}
  260 + />
  261 +
  262 + <SystemMenuDialog
  263 + mode="edit"
  264 + open={isEditOpen}
  265 + menu={editing}
  266 + onOpenChange={setIsEditOpen}
  267 + onSaved={refreshList}
  268 + />
  269 +
  270 + <DeleteSystemMenuDialog
  271 + open={isDeleteOpen}
  272 + menu={deleting}
  273 + onOpenChange={setIsDeleteOpen}
  274 + onDeleted={refreshList}
  275 + />
  276 + </div>
  277 + );
  278 +}
  279 +
  280 +function SystemMenuDialog({
  281 + mode,
  282 + open,
  283 + menu,
  284 + onOpenChange,
  285 + onSaved,
  286 +}: {
  287 + mode: "create" | "edit";
  288 + open: boolean;
  289 + menu: SystemMenuDto | null;
  290 + onOpenChange: (open: boolean) => void;
  291 + onSaved: () => void;
  292 +}) {
  293 + const isEdit = mode === "edit";
  294 + const [submitting, setSubmitting] = useState(false);
  295 +
  296 + // 按图二字段(英文展示)
  297 + const [menuName, setMenuName] = useState("");
  298 + const [routerName, setRouterName] = useState("");
  299 + const [routeUrl, setRouteUrl] = useState("");
  300 + const [menuType, setMenuType] = useState<"directory" | "menu">("menu");
  301 + const [permissionCode, setPermissionCode] = useState("");
  302 + const [parentId, setParentId] = useState("");
  303 + const [parentDirectories, setParentDirectories] = useState<SystemMenuDto[]>([]);
  304 + const [parentDirsLoading, setParentDirsLoading] = useState(false);
  305 + const [menuIcon, setMenuIcon] = useState<IconKey | "">("");
  306 + const [orderNum, setOrderNum] = useState("");
  307 + const [link, setLink] = useState("");
  308 + const [component, setComponent] = useState("");
  309 + const [query, setQuery] = useState("");
  310 + const [remark, setRemark] = useState("");
  311 +
  312 + const [isCache, setIsCache] = useState(false);
  313 + const [isShow, setIsShow] = useState(true);
  314 + const [state, setState] = useState(true);
  315 +
  316 + useEffect(() => {
  317 + if (!open) return;
  318 + setSubmitting(false);
  319 +
  320 + setMenuName(menu?.menuName ?? "");
  321 + setRouterName(menu?.routerName ?? "");
  322 + setRouteUrl(menu?.routeUrl ?? "");
  323 + // menuType: 目录/菜单(默认菜单)
  324 + // 这里按常见约定:0=Directory, 1=Menu;若后端枚举不同,再按 Swagger 调整
  325 + setMenuType(menu?.menuType === 0 ? "directory" : "menu");
  326 + setPermissionCode(menu?.permissionCode ?? "");
  327 + const rawPid = String(menu?.parentId ?? "").trim();
  328 + setParentId(
  329 + !rawPid || rawPid === "00000000-0000-0000-0000-000000000000" ? "" : rawPid,
  330 + );
  331 + setMenuIcon((menu?.menuIcon as IconKey | null) ?? "");
  332 + setOrderNum(menu?.orderNum === null || menu?.orderNum === undefined ? "" : String(menu.orderNum));
  333 + setLink(menu?.link ?? "");
  334 + setComponent(menu?.component ?? "");
  335 + setQuery(menu?.query ?? "");
  336 + setRemark(menu?.remark ?? "");
  337 +
  338 + setIsCache(!!menu?.isCache);
  339 + setIsShow(menu?.isShow ?? true);
  340 + setState(menu?.state ?? true);
  341 + }, [open, menu]);
  342 +
  343 + const PARENT_ROOT = "__parent_root__";
  344 +
  345 + useEffect(() => {
  346 + if (!open) return;
  347 + let cancelled = false;
  348 + setParentDirsLoading(true);
  349 + getDirectoryMenusForParentSelect()
  350 + .then((list) => {
  351 + if (!cancelled) setParentDirectories(list);
  352 + })
  353 + .catch(() => {
  354 + if (!cancelled) setParentDirectories([]);
  355 + })
  356 + .finally(() => {
  357 + if (!cancelled) setParentDirsLoading(false);
  358 + });
  359 + return () => {
  360 + cancelled = true;
  361 + };
  362 + }, [open]);
  363 +
  364 + const isRootParentId = (id: string) =>
  365 + !id.trim() || id === "00000000-0000-0000-0000-000000000000";
  366 +
  367 + const parentSelectOptions = useMemo(() => {
  368 + const dirs = parentDirectories.filter((d) => d.id && d.id !== menu?.id);
  369 + const pid = (parentId || "").trim();
  370 + if (pid && !isRootParentId(pid) && !dirs.some((d) => d.id === pid)) {
  371 + return [
  372 + ...dirs,
  373 + { id: pid, menuName: `(Current parent) ${pid}` } as SystemMenuDto,
  374 + ];
  375 + }
  376 + return dirs;
  377 + }, [parentDirectories, parentId, menu?.id]);
  378 +
  379 + const parentSelectValue = isRootParentId(parentId) ? PARENT_ROOT : parentId;
  380 +
  381 + const canSubmit = useMemo(() => {
  382 + return Boolean(menuName.trim() && routeUrl.trim());
  383 + }, [menuName, routeUrl]);
  384 +
  385 + const submit = async () => {
  386 + if (!canSubmit) {
  387 + toast.error("Please fill in required fields.", {
  388 + description: "Menu Name and Route URL are required.",
  389 + });
  390 + return;
  391 + }
  392 +
  393 + setSubmitting(true);
  394 + try {
  395 + const payload: SystemMenuUpsertInput = {
  396 + menuName: menuName.trim(),
  397 + routerName: routerName.trim() ? routerName.trim() : null,
  398 + routeUrl: routeUrl.trim(),
  399 + // 0=Directory, 1=Menu
  400 + menuType: menuType === "directory" ? 0 : 1,
  401 + permissionCode: permissionCode.trim() ? permissionCode.trim() : null,
  402 + parentId: isRootParentId(parentId) ? null : parentId.trim(),
  403 + menuIcon: menuIcon ? menuIcon : null,
  404 + orderNum: toIntOrNull(orderNum),
  405 + link: link.trim() ? link.trim() : null,
  406 + component: component.trim() ? component.trim() : null,
  407 + query: query.trim() ? query.trim() : null,
  408 + remark: remark.trim() ? remark.trim() : null,
  409 + isCache,
  410 + isShow,
  411 + state,
  412 + };
  413 +
  414 + if (isEdit) {
  415 + if (!menu?.id) throw new Error("Missing id.");
  416 + await updateSystemMenu(menu.id, payload);
  417 + toast.success("Menu updated.", { description: "Changes have been saved successfully." });
  418 + } else {
  419 + await createSystemMenu(payload);
  420 + toast.success("Menu created.", { description: "A new menu has been created successfully." });
  421 + }
  422 +
  423 + onOpenChange(false);
  424 + onSaved();
  425 + } catch (e: any) {
  426 + toast.error(isEdit ? "Failed to update menu." : "Failed to create menu.", {
  427 + description: e?.message ? String(e.message) : "Please try again.",
  428 + });
  429 + } finally {
  430 + setSubmitting(false);
  431 + }
  432 + };
  433 +
  434 + return (
  435 + <Dialog open={open} onOpenChange={onOpenChange}>
  436 + <DialogContent className="sm:max-w-none" style={{ width: "70%" }}>
  437 + <DialogHeader>
  438 + <DialogTitle>{isEdit ? "Edit System Menu" : "New System Menu"}</DialogTitle>
  439 + <DialogDescription>
  440 + {isEdit ? "Update system menu fields and save changes." : "Fill out the form to create a new system menu."}
  441 + </DialogDescription>
  442 + </DialogHeader>
  443 +
  444 + <div className="grid grid-cols-3 gap-6 py-2">
  445 + <div className="space-y-2">
  446 + <Label>Menu Name *</Label>
  447 + <Input value={menuName} onChange={(e) => setMenuName(e.target.value)} placeholder="e.g. Location Manager" />
  448 + </div>
  449 + <div className="space-y-2">
  450 + <Label>Route URL *</Label>
  451 + <Input value={routeUrl} onChange={(e) => setRouteUrl(e.target.value)} placeholder="e.g. /location" />
  452 + </div>
  453 + <div className="space-y-2">
  454 + <Label>Router Name</Label>
  455 + <Input value={routerName} onChange={(e) => setRouterName(e.target.value)} placeholder="e.g. location" />
  456 + </div>
  457 +
  458 + <div className="space-y-2">
  459 + <Label>Menu Type</Label>
  460 + <Select value={menuType} onValueChange={(v) => setMenuType(v as "directory" | "menu")}>
  461 + <SelectTrigger className="h-10 rounded-md border border-gray-200 bg-white">
  462 + <SelectValue />
  463 + </SelectTrigger>
  464 + <SelectContent>
  465 + <SelectItem value="directory">Directory</SelectItem>
  466 + <SelectItem value="menu">Menu</SelectItem>
  467 + </SelectContent>
  468 + </Select>
  469 + </div>
  470 + <div className="space-y-2">
  471 + <Label>Permission Code</Label>
  472 + <Input value={permissionCode} onChange={(e) => setPermissionCode(e.target.value)} placeholder="e.g. sys:menu" />
  473 + </div>
  474 + <div className="space-y-2">
  475 + <Label>Parent</Label>
  476 + <Select
  477 + value={parentSelectValue}
  478 + disabled={parentDirsLoading}
  479 + onValueChange={(v) => setParentId(v === PARENT_ROOT ? "" : v)}
  480 + >
  481 + <SelectTrigger className="h-10 rounded-md border border-gray-200 bg-white">
  482 + <SelectValue placeholder={parentDirsLoading ? "Loading…" : "Select parent directory"} />
  483 + </SelectTrigger>
  484 + <SelectContent>
  485 + <SelectItem value={PARENT_ROOT}>Root (no parent)</SelectItem>
  486 + {parentSelectOptions.map((d) => (
  487 + <SelectItem key={d.id} value={d.id!}>
  488 + {d.menuName?.trim() || d.id}
  489 + </SelectItem>
  490 + ))}
  491 + </SelectContent>
  492 + </Select>
  493 + </div>
  494 +
  495 + <div className="space-y-2">
  496 + <Label>Menu Icon</Label>
  497 + <Select value={menuIcon || "none"} onValueChange={(v) => setMenuIcon(v === "none" ? "" : (v as IconKey))}>
  498 + <SelectTrigger className="h-10 rounded-md border border-gray-200 bg-white">
  499 + <SelectValue placeholder="Select an icon" />
  500 + </SelectTrigger>
  501 + <SelectContent>
  502 + <SelectItem value="none">None</SelectItem>
  503 + {(Object.keys(ICONS) as IconKey[]).map((k) => {
  504 + const Icon = ICONS[k];
  505 + return (
  506 + <SelectItem key={k} value={k}>
  507 + <span className="flex items-center gap-2">
  508 + <Icon className="h-4 w-4" />
  509 + {k}
  510 + </span>
  511 + </SelectItem>
  512 + );
  513 + })}
  514 + </SelectContent>
  515 + </Select>
  516 + </div>
  517 + <div className="space-y-2">
  518 + <Label>Order</Label>
  519 + <Input value={orderNum} onChange={(e) => setOrderNum(e.target.value)} placeholder="e.g. 10" />
  520 + </div>
  521 + <div className="space-y-2">
  522 + <Label>Link</Label>
  523 + <Input value={link} onChange={(e) => setLink(e.target.value)} placeholder="Optional" />
  524 + </div>
  525 +
  526 + <div className="space-y-2">
  527 + <Label>Component</Label>
  528 + <Input value={component} onChange={(e) => setComponent(e.target.value)} placeholder="Optional" />
  529 + </div>
  530 + <div className="space-y-2">
  531 + <Label>Query</Label>
  532 + <Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Optional" />
  533 + </div>
  534 + <div className="space-y-2">
  535 + <Label>Remark</Label>
  536 + <Textarea value={remark} onChange={(e) => setRemark(e.target.value)} placeholder="Optional" />
  537 + </div>
  538 +
  539 + <div className="flex items-center justify-between border border-gray-200 rounded-md px-3 bg-white" style={{ height: 40 }}>
  540 + <div className="text-sm font-medium text-gray-900">Cache</div>
  541 + <Switch checked={isCache} onCheckedChange={setIsCache} />
  542 + </div>
  543 + <div className="flex items-center justify-between border border-gray-200 rounded-md px-3 bg-white" style={{ height: 40 }}>
  544 + <div className="text-sm font-medium text-gray-900">Visible</div>
  545 + <Switch checked={isShow} onCheckedChange={setIsShow} />
  546 + </div>
  547 + <div className="flex items-center justify-between border border-gray-200 rounded-md px-3 bg-white" style={{ height: 40 }}>
  548 + <div className="text-sm font-medium text-gray-900">Enabled</div>
  549 + <Switch checked={state} onCheckedChange={setState} />
  550 + </div>
  551 + </div>
  552 +
  553 + <DialogFooter className="flex-row flex-wrap justify-end">
  554 + <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}>
  555 + Cancel
  556 + </Button>
  557 + <Button className="min-w-24 bg-blue-600 text-white hover:bg-blue-700" disabled={submitting} onClick={submit}>
  558 + {submitting ? "Saving..." : isEdit ? "Save Changes" : "Create"}
  559 + </Button>
  560 + </DialogFooter>
  561 + </DialogContent>
  562 + </Dialog>
  563 + );
  564 +}
  565 +
  566 +function DeleteSystemMenuDialog({
  567 + open,
  568 + menu,
  569 + onOpenChange,
  570 + onDeleted,
  571 +}: {
  572 + open: boolean;
  573 + menu: SystemMenuDto | null;
  574 + onOpenChange: (open: boolean) => void;
  575 + onDeleted: () => void;
  576 +}) {
  577 + const [submitting, setSubmitting] = useState(false);
  578 +
  579 + const name = useMemo(() => {
  580 + const n = (menu?.menuName ?? "").trim();
  581 + const p = (menu?.routeUrl ?? "").trim();
  582 + if (n && p) return `${n} (${p})`;
  583 + return n || p || "this menu";
  584 + }, [menu?.menuName, menu?.routeUrl]);
  585 +
  586 + const submit = async () => {
  587 + if (!menu?.id) return;
  588 + setSubmitting(true);
  589 + try {
  590 + await deleteSystemMenu(menu.id);
  591 + toast.success("Menu deleted.", { description: "The menu has been removed successfully." });
  592 + onOpenChange(false);
  593 + onDeleted();
  594 + } catch (e: any) {
  595 + toast.error("Failed to delete menu.", {
  596 + description: e?.message ? String(e.message) : "Please try again.",
  597 + });
  598 + } finally {
  599 + setSubmitting(false);
  600 + }
  601 + };
  602 +
  603 + return (
  604 + <Dialog open={open} onOpenChange={onOpenChange}>
  605 + <DialogContent className="sm:max-w-none" style={{ width: "30%" }}>
  606 + <DialogHeader>
  607 + <DialogTitle>Delete System Menu</DialogTitle>
  608 + <DialogDescription>This action cannot be undone.</DialogDescription>
  609 + </DialogHeader>
  610 + <div className="text-sm text-gray-700">
  611 + Are you sure you want to delete <span className="font-medium">{name}</span>?
  612 + </div>
  613 + <DialogFooter className="flex-row flex-wrap justify-end">
  614 + <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}>
  615 + Cancel
  616 + </Button>
  617 + <Button className="min-w-24" variant="destructive" disabled={submitting} onClick={submit}>
  618 + {submitting ? "Deleting..." : "Delete"}
  619 + </Button>
  620 + </DialogFooter>
  621 + </DialogContent>
  622 + </Dialog>
  623 + );
  624 +}
  625 +
... ...
美国版/Food Labeling Management Platform/src/components/ui/button.tsx
... ... @@ -34,25 +34,24 @@ const buttonVariants = cva(
34 34 },
35 35 );
36 36  
37   -function Button({
38   - className,
39   - variant,
40   - size,
41   - asChild = false,
42   - ...props
43   -}: React.ComponentProps<"button"> &
44   - VariantProps<typeof buttonVariants> & {
45   - asChild?: boolean;
46   - }) {
  37 +const Button = React.forwardRef<
  38 + HTMLButtonElement,
  39 + React.ComponentPropsWithoutRef<"button"> &
  40 + VariantProps<typeof buttonVariants> & {
  41 + asChild?: boolean;
  42 + }
  43 +>(({ className, variant, size, asChild = false, ...props }, ref) => {
47 44 const Comp = asChild ? Slot : "button";
48 45  
49 46 return (
50 47 <Comp
  48 + ref={ref as React.Ref<HTMLButtonElement>}
51 49 data-slot="button"
52 50 className={cn(buttonVariants({ variant, size, className }))}
53 51 {...props}
54 52 />
55 53 );
56   -}
  54 +});
  55 +Button.displayName = "Button";
57 56  
58 57 export { Button, buttonVariants };
... ...
美国版/Food Labeling Management Platform/src/lib/apiClient.ts 0 → 100644
  1 +export type ApiClientOptions = {
  2 + baseUrl?: string;
  3 + /**
  4 + * Optional auth token provider.
  5 + * If present, request will add `Authorization: Bearer <token>`.
  6 + */
  7 + getToken?: () => string | null | undefined;
  8 +};
  9 +
  10 +export type AbpErrorPayload = {
  11 + error?: {
  12 + code?: string;
  13 + message?: string;
  14 + details?: string;
  15 + validationErrors?: { message?: string; members?: string[] }[];
  16 + };
  17 +};
  18 +
  19 +type WrappedResponse<T = unknown> = {
  20 + data?: T;
  21 + succeeded?: boolean;
  22 + statusCode?: number;
  23 + errors?: unknown;
  24 + extras?: unknown;
  25 + timestamp?: unknown;
  26 +};
  27 +
  28 +export class ApiError extends Error {
  29 + status: number;
  30 + payload?: unknown;
  31 +
  32 + constructor(message: string, status: number, payload?: unknown) {
  33 + super(message);
  34 + this.name = "ApiError";
  35 + this.status = status;
  36 + this.payload = payload;
  37 + }
  38 +}
  39 +
  40 +function joinUrl(baseUrl: string, path: string): string {
  41 + if (!baseUrl) return path;
  42 + const b = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
  43 + const p = path.startsWith("/") ? path : `/${path}`;
  44 + return `${b}${p}`;
  45 +}
  46 +
  47 +function toQueryString(params: Record<string, unknown>): string {
  48 + const qs = new URLSearchParams();
  49 + for (const [k, v] of Object.entries(params)) {
  50 + if (v === undefined || v === null || v === "") continue;
  51 + if (typeof v === "boolean") {
  52 + qs.set(k, v ? "true" : "false");
  53 + continue;
  54 + }
  55 + qs.set(k, String(v));
  56 + }
  57 + const s = qs.toString();
  58 + return s ? `?${s}` : "";
  59 +}
  60 +
  61 +function getAbpErrorMessage(payload: unknown): string | null {
  62 + const p = payload as AbpErrorPayload | null | undefined;
  63 + const msg = p?.error?.message?.trim();
  64 + if (msg) return msg;
  65 + return null;
  66 +}
  67 +
  68 +function normalizePagedResultShape(x: unknown): unknown {
  69 + // 部分接口直接把列表放在 data 里(无 items/totalCount)
  70 + if (Array.isArray(x)) {
  71 + return { items: x, totalCount: x.length };
  72 + }
  73 + if (!x || typeof x !== "object") return x;
  74 + const o = x as Record<string, unknown>;
  75 +
  76 + // 兼容 ABP/PagedResultDto 的两种命名:TotalCount/Items vs totalCount/items
  77 + const hasUpper = "TotalCount" in o || "Items" in o;
  78 + const hasLower = "totalCount" in o || "items" in o;
  79 + if (hasUpper && !hasLower) {
  80 + return {
  81 + ...o,
  82 + totalCount: o.TotalCount,
  83 + items: o.Items,
  84 + };
  85 + }
  86 +
  87 + // 兼容部分接口返回:{ data: [...], totalCount: number }
  88 + if (!("items" in o) && Array.isArray(o.data) && typeof o.totalCount === "number") {
  89 + return {
  90 + ...o,
  91 + items: o.data,
  92 + };
  93 + }
  94 + // 兼容部分接口返回:{ Data: [...], TotalCount: number }
  95 + if (!("items" in o) && Array.isArray((o as any).Data) && typeof (o as any).TotalCount === "number") {
  96 + return {
  97 + ...o,
  98 + totalCount: (o as any).TotalCount,
  99 + items: (o as any).Data,
  100 + };
  101 + }
  102 + return x;
  103 +}
  104 +
  105 +export function createApiClient(opts: ApiClientOptions = {}) {
  106 + const baseUrl = opts.baseUrl ?? import.meta.env.VITE_API_BASE_URL ?? "http://localhost:19001";
  107 + const getToken = opts.getToken;
  108 +
  109 + async function requestJson<T>(input: {
  110 + path: string;
  111 + method: "GET" | "POST" | "PUT" | "DELETE";
  112 + query?: Record<string, unknown>;
  113 + body?: unknown;
  114 + signal?: AbortSignal;
  115 + /**
  116 + * ABP conventional controller usually uses `api/app`.
  117 + * Keep it configurable per call.
  118 + */
  119 + prefix?: string;
  120 + }): Promise<T> {
  121 + const prefix = input.prefix ?? "/api/app";
  122 + const url = joinUrl(baseUrl, `${prefix}${input.path}${toQueryString(input.query ?? {})}`);
  123 +
  124 + const headers: Record<string, string> = {
  125 + "Content-Type": "application/json",
  126 + };
  127 + const token = getToken?.();
  128 + if (token) headers.Authorization = `Bearer ${token}`;
  129 +
  130 + const res = await fetch(url, {
  131 + method: input.method,
  132 + headers,
  133 + body: input.body === undefined ? undefined : JSON.stringify(input.body),
  134 + signal: input.signal,
  135 + });
  136 +
  137 + const contentType = res.headers.get("content-type") ?? "";
  138 + const isJson = contentType.includes("application/json");
  139 + const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => "");
  140 +
  141 + if (!res.ok) {
  142 + const abpMsg = getAbpErrorMessage(payload);
  143 + const msg = abpMsg ?? (typeof payload === "string" && payload.trim() ? payload : "Request failed.");
  144 + throw new ApiError(msg, res.status, payload);
  145 + }
  146 +
  147 + // 部分宿主会把真实返回包在 { data, succeeded, statusCode, ... } 中(如抓包所示)。
  148 + // 为了不污染各业务 service,这里统一做一次解包:优先返回 data。
  149 + if (payload && typeof payload === "object" && "data" in (payload as Record<string, unknown>)) {
  150 + const wrapped = payload as WrappedResponse<T>;
  151 + return normalizePagedResultShape(wrapped.data ?? null) as T;
  152 + }
  153 +
  154 + return normalizePagedResultShape(payload) as T;
  155 + }
  156 +
  157 + return { requestJson };
  158 +}
  159 +
... ...
美国版/Food Labeling Management Platform/src/services/locationService.ts 0 → 100644
  1 +import { createApiClient } from "../lib/apiClient";
  2 +import type {
  3 + LocationCreateInput,
  4 + LocationDto,
  5 + LocationGetListInput,
  6 + LocationUpdateInput,
  7 + PagedResultDto,
  8 +} from "../types/location";
  9 +
  10 +const api = createApiClient({
  11 + // 如果项目后续接入登录,可在这里加 token 获取逻辑
  12 + getToken: () => {
  13 + try {
  14 + return localStorage.getItem("access_token") ?? localStorage.getItem("token") ?? null;
  15 + } catch {
  16 + return null;
  17 + }
  18 + },
  19 +});
  20 +
  21 +/**
  22 + * ABP Conventional Controller 默认路由通常为:
  23 + * - GET /api/app/location
  24 + * - POST /api/app/location
  25 + * 这里按该约定实现;如果后端实际路由不同,以 Swagger 为准调整 path。
  26 + */
  27 +export async function getLocations(input: LocationGetListInput, signal?: AbortSignal): Promise<PagedResultDto<LocationDto>> {
  28 + // 先按 ABP 常见 GET + query 调用;若后端只支持 POST,会在页面侧提示错误(便于联调时调整)。
  29 + return api.requestJson<PagedResultDto<LocationDto>>({
  30 + path: "/location",
  31 + method: "GET",
  32 + query: {
  33 + SkipCount: input.skipCount,
  34 + MaxResultCount: input.maxResultCount,
  35 + Sorting: input.sorting,
  36 + Keyword: input.keyword,
  37 + Partner: input.partner,
  38 + GroupName: input.groupName,
  39 + State: input.state,
  40 + },
  41 + signal,
  42 + });
  43 +}
  44 +
  45 +export async function createLocation(input: LocationCreateInput): Promise<LocationDto> {
  46 + return api.requestJson<LocationDto>({
  47 + path: "/location",
  48 + method: "POST",
  49 + body: {
  50 + // Swagger 示例为 camelCase(ABP 默认 JSON 命名策略)
  51 + partner: input.partner,
  52 + groupName: input.groupName,
  53 + locationCode: input.locationCode,
  54 + locationName: input.locationName,
  55 + street: input.street,
  56 + city: input.city,
  57 + stateCode: input.stateCode,
  58 + country: input.country,
  59 + zipCode: input.zipCode,
  60 + phone: input.phone,
  61 + email: input.email,
  62 + latitude: input.latitude,
  63 + longitude: input.longitude,
  64 + state: input.state ?? true,
  65 + },
  66 + });
  67 +}
  68 +
  69 +/**
  70 + * ABP Conventional Controller 的 UpdateAsync 常见路由为:
  71 + * - PUT /api/app/location/{id}
  72 + * 以 Swagger 为准;若后端使用其它形式(如 /location?id=xxx),在此调整即可。
  73 + */
  74 +export async function updateLocation(id: string, input: LocationUpdateInput): Promise<LocationDto> {
  75 + return api.requestJson<LocationDto>({
  76 + path: `/location/${encodeURIComponent(id)}`,
  77 + method: "PUT",
  78 + body: {
  79 + partner: input.partner,
  80 + groupName: input.groupName,
  81 + locationCode: input.locationCode,
  82 + locationName: input.locationName,
  83 + street: input.street,
  84 + city: input.city,
  85 + stateCode: input.stateCode,
  86 + country: input.country,
  87 + zipCode: input.zipCode,
  88 + phone: input.phone,
  89 + email: input.email,
  90 + latitude: input.latitude,
  91 + longitude: input.longitude,
  92 + state: input.state ?? true,
  93 + },
  94 + });
  95 +}
  96 +
  97 +/**
  98 + * ABP Conventional Controller 的 DeleteAsync 常见路由为:
  99 + * - DELETE /api/app/location/{id}
  100 + * 以 Swagger 为准;若后端路由不同,在此调整 path。
  101 + */
  102 +export async function deleteLocation(id: string): Promise<void> {
  103 + await api.requestJson<unknown>({
  104 + path: `/location/${encodeURIComponent(id)}`,
  105 + method: "DELETE",
  106 + });
  107 +}
  108 +
... ...
美国版/Food Labeling Management Platform/src/services/menuService.ts 0 → 100644
  1 +import { createApiClient } from "../lib/apiClient";
  2 +import type { MenuCreateInput, MenuDto, MenuGetListInput, MenuUpdateInput, PagedResultDto } from "../types/menu";
  3 +
  4 +const api = createApiClient({
  5 + getToken: () => {
  6 + try {
  7 + return localStorage.getItem("access_token") ?? localStorage.getItem("token") ?? null;
  8 + } catch {
  9 + return null;
  10 + }
  11 + },
  12 +});
  13 +
  14 +// Keep routes easy to adjust during integration (Swagger not accessible).
  15 +const MENU_PATH = "/menu";
  16 +
  17 +export type MenuServiceRouteMode = "abp" | "plain";
  18 +
  19 +function prefixFor(mode: MenuServiceRouteMode | undefined): string {
  20 + return mode === "plain" ? "" : "/api/app";
  21 +}
  22 +
  23 +export async function getMenus(
  24 + input: MenuGetListInput,
  25 + signal?: AbortSignal,
  26 + routeMode: MenuServiceRouteMode = "abp",
  27 +): Promise<PagedResultDto<MenuDto>> {
  28 + return api.requestJson<PagedResultDto<MenuDto>>({
  29 + path: MENU_PATH,
  30 + method: "GET",
  31 + query: {
  32 + SkipCount: input.skipCount,
  33 + MaxResultCount: input.maxResultCount,
  34 + Sorting: input.sorting,
  35 + Keyword: input.keyword,
  36 + },
  37 + signal,
  38 + prefix: prefixFor(routeMode),
  39 + });
  40 +}
  41 +
  42 +export async function createMenu(input: MenuCreateInput, routeMode: MenuServiceRouteMode = "abp"): Promise<MenuDto> {
  43 + return api.requestJson<MenuDto>({
  44 + path: MENU_PATH,
  45 + method: "POST",
  46 + body: {
  47 + name: input.name,
  48 + path: input.path,
  49 + icon: input.icon,
  50 + order: input.order,
  51 + parentId: input.parentId,
  52 + isEnabled: input.isEnabled ?? true,
  53 + },
  54 + prefix: prefixFor(routeMode),
  55 + });
  56 +}
  57 +
  58 +export async function updateMenu(
  59 + id: string,
  60 + input: MenuUpdateInput,
  61 + routeMode: MenuServiceRouteMode = "abp",
  62 +): Promise<MenuDto> {
  63 + return api.requestJson<MenuDto>({
  64 + path: `${MENU_PATH}/${encodeURIComponent(id)}`,
  65 + method: "PUT",
  66 + body: {
  67 + name: input.name,
  68 + path: input.path,
  69 + icon: input.icon,
  70 + order: input.order,
  71 + parentId: input.parentId,
  72 + isEnabled: input.isEnabled ?? true,
  73 + },
  74 + prefix: prefixFor(routeMode),
  75 + });
  76 +}
  77 +
  78 +export async function deleteMenu(id: string, routeMode: MenuServiceRouteMode = "abp"): Promise<void> {
  79 + await api.requestJson<unknown>({
  80 + path: `${MENU_PATH}/${encodeURIComponent(id)}`,
  81 + method: "DELETE",
  82 + prefix: prefixFor(routeMode),
  83 + });
  84 +}
  85 +
... ...
美国版/Food Labeling Management Platform/src/services/rbacRoleMenuService.ts 0 → 100644
  1 +import { createApiClient } from "../lib/apiClient";
  2 +
  3 +const api = createApiClient({
  4 + getToken: () => {
  5 + try {
  6 + return localStorage.getItem("access_token") ?? localStorage.getItem("token") ?? null;
  7 + } catch {
  8 + return null;
  9 + }
  10 + },
  11 +});
  12 +
  13 +const PATH = "/rbac-role-menu";
  14 +
  15 +export type RbacRoleMenuSetInput = {
  16 + roleId: string;
  17 + menuIds: string[];
  18 +};
  19 +
  20 +/**
  21 + * Swagger:
  22 + * - POST /api/app/rbac-role-menu/set
  23 + * - GET /api/app/rbac-role-menu/menu-ids/{roleId}
  24 + * - DELETE /api/app/rbac-role-menu (body: { roleId, menuIds })
  25 + */
  26 +export async function setRoleMenus(input: RbacRoleMenuSetInput): Promise<void> {
  27 + await api.requestJson<unknown>({
  28 + path: `${PATH}/set`,
  29 + method: "POST",
  30 + body: input,
  31 + });
  32 +}
  33 +
  34 +export async function getRoleMenuIds(roleId: string): Promise<string[]> {
  35 + const res = await api.requestJson<unknown>({
  36 + path: `${PATH}/menu-ids/${encodeURIComponent(roleId)}`,
  37 + method: "GET",
  38 + });
  39 + // 兼容后端直接返回数组 或包一层 { items }
  40 + if (Array.isArray(res)) return res as string[];
  41 + const maybe = res as { items?: unknown };
  42 + return Array.isArray(maybe?.items) ? (maybe.items as string[]) : [];
  43 +}
  44 +
  45 +export async function deleteRoleMenus(input: RbacRoleMenuSetInput): Promise<void> {
  46 + await api.requestJson<unknown>({
  47 + path: PATH,
  48 + method: "DELETE",
  49 + body: input,
  50 + });
  51 +}
  52 +
... ...
美国版/Food Labeling Management Platform/src/services/rbacRoleService.ts 0 → 100644
  1 +import { createApiClient } from "../lib/apiClient";
  2 +import type { RoleDto } from "../types/role";
  3 +
  4 +const api = createApiClient({
  5 + getToken: () => {
  6 + try {
  7 + return localStorage.getItem("access_token") ?? localStorage.getItem("token") ?? null;
  8 + } catch {
  9 + return null;
  10 + }
  11 + },
  12 +});
  13 +
  14 +const PATH = "/rbac-role";
  15 +
  16 +export type RbacRoleUpsertInput = {
  17 + roleName: string;
  18 + roleCode: string;
  19 + remark?: string | null;
  20 + dataScope?: number | null;
  21 + state: boolean;
  22 + orderNum?: number | null;
  23 +};
  24 +
  25 +export async function createRbacRole(input: RbacRoleUpsertInput): Promise<RoleDto> {
  26 + return api.requestJson<RoleDto>({
  27 + path: PATH,
  28 + method: "POST",
  29 + body: input,
  30 + });
  31 +}
  32 +
  33 +export async function updateRbacRole(id: string, input: RbacRoleUpsertInput): Promise<RoleDto> {
  34 + return api.requestJson<RoleDto>({
  35 + path: `${PATH}/${encodeURIComponent(id)}`,
  36 + method: "PUT",
  37 + body: input,
  38 + });
  39 +}
  40 +
  41 +/**
  42 + * Swagger(常见约定):DELETE /api/app/rbac-role,Body 为 ID 数组
  43 + */
  44 +export async function deleteRbacRoles(ids: string[]): Promise<void> {
  45 + if (!ids.length) return;
  46 + await api.requestJson<unknown>({
  47 + path: PATH,
  48 + method: "DELETE",
  49 + body: ids,
  50 + });
  51 +}
  52 +
  53 +export async function deleteRbacRole(id: string): Promise<void> {
  54 + await deleteRbacRoles([id]);
  55 +}
  56 +
... ...
美国版/Food Labeling Management Platform/src/services/roleService.ts 0 → 100644
  1 +import { createApiClient } from "../lib/apiClient";
  2 +import type { PagedResultDto, RoleDto, RoleGetListInput } from "../types/role";
  3 +
  4 +const api = createApiClient({
  5 + getToken: () => {
  6 + try {
  7 + return localStorage.getItem("access_token") ?? localStorage.getItem("token") ?? null;
  8 + } catch {
  9 + return null;
  10 + }
  11 + },
  12 +});
  13 +
  14 +// ABP Conventional Controller for RoleService
  15 +const PATH = "/role";
  16 +
  17 +export async function getRoles(input: RoleGetListInput, signal?: AbortSignal): Promise<PagedResultDto<RoleDto>> {
  18 + return api.requestJson<PagedResultDto<RoleDto>>({
  19 + path: PATH,
  20 + method: "GET",
  21 + query: {
  22 + SkipCount: input.skipCount,
  23 + MaxResultCount: input.maxResultCount,
  24 + RoleName: input.roleName,
  25 + RoleCode: input.roleCode,
  26 + State: input.state,
  27 + },
  28 + signal,
  29 + });
  30 +}
  31 +
... ...
美国版/Food Labeling Management Platform/src/services/systemMenuService.ts 0 → 100644
  1 +import { createApiClient } from "../lib/apiClient";
  2 +import type {
  3 + PagedResultDto,
  4 + RbacMenuTreeNode,
  5 + SystemMenuDto,
  6 + SystemMenuGetListInput,
  7 + SystemMenuUpsertInput,
  8 +} from "../types/systemMenu";
  9 +
  10 +const api = createApiClient({
  11 + getToken: () => {
  12 + try {
  13 + return localStorage.getItem("access_token") ?? localStorage.getItem("token") ?? null;
  14 + } catch {
  15 + return null;
  16 + }
  17 + },
  18 +});
  19 +
  20 +// Swagger: /api/app/rbac-menu
  21 +const PATH = "/rbac-menu";
  22 +
  23 +/** 兼容后端 PascalCase 字段 */
  24 +export function normalizeSystemMenuDto(row: unknown): SystemMenuDto {
  25 + if (!row || typeof row !== "object") return { id: "" };
  26 + const r = row as Record<string, unknown>;
  27 + return {
  28 + id: String(r.id ?? r.Id ?? ""),
  29 + orderNum: (r.orderNum ?? r.OrderNum) as number | null | undefined,
  30 + state: (r.state ?? r.State) as boolean | null | undefined,
  31 + menuName: (r.menuName ?? r.MenuName) as string | null | undefined,
  32 + routerName: (r.routerName ?? r.RouterName) as string | null | undefined,
  33 + menuType: (r.menuType ?? r.MenuType) as number | null | undefined,
  34 + permissionCode: (r.permissionCode ?? r.PermissionCode) as string | null | undefined,
  35 + parentId: (r.parentId ?? r.ParentId) as string | null | undefined,
  36 + menuIcon: (r.menuIcon ?? r.MenuIcon) as string | null | undefined,
  37 + routeUrl: (r.routeUrl ?? r.RouteUrl) as string | null | undefined,
  38 + link: (r.link ?? r.Link) as string | null | undefined,
  39 + isCache: (r.isCache ?? r.IsCache) as boolean | null | undefined,
  40 + isShow: (r.isShow ?? r.IsShow) as boolean | null | undefined,
  41 + remark: (r.remark ?? r.Remark) as string | null | undefined,
  42 + component: (r.component ?? r.Component) as string | null | undefined,
  43 + menuSource: (r.menuSource ?? r.MenuSource) as number | null | undefined,
  44 + query: (r.query ?? r.Query) as string | null | undefined,
  45 + concurrencyStamp: (r.concurrencyStamp ?? r.ConcurrencyStamp) as string | null | undefined,
  46 + };
  47 +}
  48 +
  49 +function toPagedMenus(raw: unknown): PagedResultDto<SystemMenuDto> {
  50 + if (Array.isArray(raw)) {
  51 + return {
  52 + items: raw.map(normalizeSystemMenuDto),
  53 + totalCount: raw.length,
  54 + };
  55 + }
  56 + const o = raw as Record<string, unknown>;
  57 + const itemsRaw = (o.items ?? o.Items ?? []) as unknown[];
  58 + const total =
  59 + typeof o.totalCount === "number"
  60 + ? o.totalCount
  61 + : typeof o.TotalCount === "number"
  62 + ? (o.TotalCount as number)
  63 + : itemsRaw.length;
  64 + return {
  65 + items: itemsRaw.map(normalizeSystemMenuDto),
  66 + totalCount: total,
  67 + };
  68 +}
  69 +
  70 +export async function getSystemMenus(
  71 + input: SystemMenuGetListInput,
  72 + signal?: AbortSignal,
  73 +): Promise<PagedResultDto<SystemMenuDto>> {
  74 + const raw = await api.requestJson<unknown>({
  75 + path: PATH,
  76 + method: "GET",
  77 + query: {
  78 + SkipCount: input.skipCount,
  79 + MaxResultCount: input.maxResultCount,
  80 + Sorting: input.sorting,
  81 + Keyword: input.keyword,
  82 + },
  83 + signal,
  84 + });
  85 + return toPagedMenus(raw);
  86 +}
  87 +
  88 +function normalizeRbacMenuTreeNode(row: unknown): RbacMenuTreeNode {
  89 + const base = normalizeSystemMenuDto(row);
  90 + const r = row as Record<string, unknown> | null | undefined;
  91 + const childrenRaw = (r?.children ?? (r as any)?.Children) as unknown;
  92 + const children = Array.isArray(childrenRaw) ? childrenRaw.map(normalizeRbacMenuTreeNode) : undefined;
  93 + return {
  94 + ...base,
  95 + children,
  96 + };
  97 +}
  98 +
  99 +function toMenuTree(raw: unknown): RbacMenuTreeNode[] {
  100 + if (Array.isArray(raw)) return raw.map(normalizeRbacMenuTreeNode);
  101 + if (!raw || typeof raw !== "object") return [];
  102 + const o = raw as Record<string, unknown>;
  103 + const items = (o.items ?? o.Items ?? o.data ?? (o as any).Data) as unknown;
  104 + if (Array.isArray(items)) return items.map(normalizeRbacMenuTreeNode);
  105 + return [];
  106 +}
  107 +
  108 +/**
  109 + * Swagger: GET /api/app/rbac-menu/tree (no params)
  110 + */
  111 +export async function getRbacMenuTree(signal?: AbortSignal): Promise<RbacMenuTreeNode[]> {
  112 + const raw = await api.requestJson<unknown>({
  113 + path: `${PATH}/tree`,
  114 + method: "GET",
  115 + signal,
  116 + });
  117 + return toMenuTree(raw);
  118 +}
  119 +
  120 +/**
  121 + * 父级下拉:所有目录类型菜单(menuType === 0),多页合并。
  122 + */
  123 +export async function getDirectoryMenusForParentSelect(signal?: AbortSignal): Promise<SystemMenuDto[]> {
  124 + const byId = new Map<string, SystemMenuDto>();
  125 + let page = 1;
  126 + const size = 500;
  127 + for (;;) {
  128 + const res = await getSystemMenus({ skipCount: page, maxResultCount: size }, signal);
  129 + const items = res.items ?? [];
  130 + for (const m of items) {
  131 + if (m.menuType !== 0 || !m.id) continue;
  132 + if (!byId.has(m.id)) byId.set(m.id, m);
  133 + }
  134 + if (items.length < size) break;
  135 + page += 1;
  136 + if (page > 100) break;
  137 + }
  138 + return Array.from(byId.values()).sort((a, b) => (a.orderNum ?? 0) - (b.orderNum ?? 0));
  139 +}
  140 +
  141 +export async function createSystemMenu(input: SystemMenuUpsertInput): Promise<SystemMenuDto> {
  142 + const raw = await api.requestJson<unknown>({
  143 + path: PATH,
  144 + method: "POST",
  145 + body: input,
  146 + });
  147 + return normalizeSystemMenuDto(raw);
  148 +}
  149 +
  150 +export async function updateSystemMenu(id: string, input: SystemMenuUpsertInput): Promise<SystemMenuDto> {
  151 + const raw = await api.requestJson<unknown>({
  152 + path: `${PATH}/${encodeURIComponent(id)}`,
  153 + method: "PUT",
  154 + body: input,
  155 + });
  156 + return normalizeSystemMenuDto(raw);
  157 +}
  158 +
  159 +/**
  160 + * Swagger: DELETE /api/app/rbac-menu,Body 为 ID 数组
  161 + */
  162 +export async function deleteSystemMenus(ids: string[]): Promise<void> {
  163 + if (!ids.length) return;
  164 + await api.requestJson<unknown>({
  165 + path: PATH,
  166 + method: "DELETE",
  167 + body: ids,
  168 + });
  169 +}
  170 +
  171 +export async function deleteSystemMenu(id: string): Promise<void> {
  172 + await deleteSystemMenus([id]);
  173 +}
... ...
美国版/Food Labeling Management Platform/src/types/location.ts 0 → 100644
  1 +export type LocationDto = {
  2 + id: string;
  3 + partner?: string | null;
  4 + groupName?: string | null;
  5 + locationCode?: string | null;
  6 + locationName?: string | null;
  7 + street?: string | null;
  8 + city?: string | null;
  9 + stateCode?: string | null;
  10 + country?: string | null;
  11 + zipCode?: string | null;
  12 + phone?: string | null;
  13 + email?: string | null;
  14 + latitude?: number | null;
  15 + longitude?: number | null;
  16 + state?: boolean | null;
  17 +};
  18 +
  19 +export type PagedResultDto<T> = {
  20 + totalCount: number;
  21 + items: T[];
  22 +};
  23 +
  24 +export type LocationGetListInput = {
  25 + skipCount: number;
  26 + maxResultCount: number;
  27 + sorting?: string;
  28 + keyword?: string;
  29 + partner?: string;
  30 + groupName?: string;
  31 + state?: boolean;
  32 +};
  33 +
  34 +export type LocationCreateInput = {
  35 + partner?: string | null;
  36 + groupName?: string | null;
  37 + locationCode: string;
  38 + locationName: string;
  39 + street?: string | null;
  40 + city?: string | null;
  41 + stateCode?: string | null;
  42 + country?: string | null;
  43 + zipCode?: string | null;
  44 + phone?: string | null;
  45 + email?: string | null;
  46 + latitude?: number | null;
  47 + longitude?: number | null;
  48 + state?: boolean;
  49 +};
  50 +
  51 +export type LocationUpdateInput = LocationCreateInput;
  52 +
... ...
美国版/Food Labeling Management Platform/src/types/menu.ts 0 → 100644
  1 +export type MenuDto = {
  2 + id: string;
  3 + name: string;
  4 + path: string;
  5 + icon?: string | null;
  6 + order?: number | null;
  7 + parentId?: string | null;
  8 + isEnabled?: boolean | null;
  9 + createdAt?: string | null;
  10 +};
  11 +
  12 +export type PagedResultDto<T> = {
  13 + totalCount: number;
  14 + items: T[];
  15 +};
  16 +
  17 +export type MenuGetListInput = {
  18 + skipCount: number;
  19 + maxResultCount: number;
  20 + keyword?: string;
  21 + sorting?: string;
  22 +};
  23 +
  24 +export type MenuCreateInput = {
  25 + name: string;
  26 + path: string;
  27 + icon?: string | null;
  28 + order?: number | null;
  29 + parentId?: string | null;
  30 + isEnabled?: boolean;
  31 +};
  32 +
  33 +export type MenuUpdateInput = MenuCreateInput;
  34 +
... ...
美国版/Food Labeling Management Platform/src/types/role.ts 0 → 100644
  1 +export type RoleDto = {
  2 + id: string;
  3 + roleName?: string | null;
  4 + roleCode?: string | null;
  5 + remark?: string | null;
  6 + state?: boolean | null;
  7 + orderNum?: number | null;
  8 + creationTime?: string | null;
  9 +};
  10 +
  11 +export type PagedResultDto<T> = {
  12 + totalCount: number;
  13 + items: T[];
  14 +};
  15 +
  16 +export type RoleGetListInput = {
  17 + skipCount: number; // pageIndex (1-based)
  18 + maxResultCount: number; // pageSize
  19 + roleName?: string;
  20 + roleCode?: string;
  21 + state?: boolean;
  22 +};
  23 +
... ...
美国版/Food Labeling Management Platform/src/types/systemMenu.ts 0 → 100644
  1 +export type SystemMenuDto = {
  2 + id: string;
  3 + orderNum?: number | null;
  4 + state?: boolean | null;
  5 +
  6 + menuName?: string | null;
  7 + routerName?: string | null;
  8 + menuType?: number | null;
  9 + permissionCode?: string | null;
  10 + parentId?: string | null;
  11 + menuIcon?: string | null;
  12 + routeUrl?: string | null;
  13 + link?: string | null;
  14 + isCache?: boolean | null;
  15 + isShow?: boolean | null;
  16 + remark?: string | null;
  17 + component?: string | null;
  18 + menuSource?: number | null;
  19 + query?: string | null;
  20 + concurrencyStamp?: string | null;
  21 +};
  22 +
  23 +export type RbacMenuTreeNode = SystemMenuDto & {
  24 + children?: RbacMenuTreeNode[];
  25 +};
  26 +
  27 +export type PagedResultDto<T> = {
  28 + totalCount: number;
  29 + items: T[];
  30 +};
  31 +
  32 +export type SystemMenuGetListInput = {
  33 + skipCount: number;
  34 + maxResultCount: number;
  35 + keyword?: string;
  36 + sorting?: string;
  37 +};
  38 +
  39 +export type SystemMenuUpsertInput = {
  40 + orderNum?: number | null;
  41 + state?: boolean;
  42 +
  43 + menuName: string;
  44 + routerName?: string | null;
  45 + menuType?: number | null;
  46 + permissionCode?: string | null;
  47 + parentId?: string | null;
  48 + menuIcon?: string | null;
  49 + routeUrl: string;
  50 + link?: string | null;
  51 + isCache?: boolean;
  52 + isShow?: boolean;
  53 + remark?: string | null;
  54 + component?: string | null;
  55 + menuSource?: number | null;
  56 + query?: string | null;
  57 + concurrencyStamp?: string | null;
  58 +};
  59 +
... ...