Commit 0afa2a004dd9672b95b29278e271bb6fe4752327
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
美国版/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 './components/products/ProductsView'; |
| 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 | + | ... | ... |