Commit 0e27ddc820fd26dd05165eb2a4241b0df5cb2ccd
1 parent
acf08c9c
标签
Showing
34 changed files
with
6885 additions
and
1542 deletions
label-template-template-1773988794039 (1).json deleted
| 1 | -{ | |
| 2 | - "id": "template-1773988794039", | |
| 3 | - "name": "未命名模板", | |
| 4 | - "labelType": "PRICE", | |
| 5 | - "unit": "inch", | |
| 6 | - "width": 4, | |
| 7 | - "height": 6, | |
| 8 | - "appliedLocation": "ALL", | |
| 9 | - "showRuler": true, | |
| 10 | - "showGrid": true, | |
| 11 | - "elements": [ | |
| 12 | - { | |
| 13 | - "id": "el-1773989351080-vqc03nr", | |
| 14 | - "type": "IMAGE", | |
| 15 | - "x": 32, | |
| 16 | - "y": 24, | |
| 17 | - "width": 60, | |
| 18 | - "height": 60, | |
| 19 | - "rotation": "horizontal", | |
| 20 | - "border": "none", | |
| 21 | - "config": { | |
| 22 | - "src": "", | |
| 23 | - "scaleMode": "contain" | |
| 24 | - } | |
| 25 | - }, | |
| 26 | - { | |
| 27 | - "id": "el-1773989452538-0ejrxoe", | |
| 28 | - "type": "TEXT_STATIC", | |
| 29 | - "x": 32, | |
| 30 | - "y": 104, | |
| 31 | - "width": 120, | |
| 32 | - "height": 24, | |
| 33 | - "rotation": "horizontal", | |
| 34 | - "border": "none", | |
| 35 | - "config": { | |
| 36 | - "text": "文本", | |
| 37 | - "fontFamily": "Arial", | |
| 38 | - "fontSize": 14, | |
| 39 | - "fontWeight": "normal", | |
| 40 | - "textAlign": "left" | |
| 41 | - } | |
| 42 | - }, | |
| 43 | - { | |
| 44 | - "id": "el-1773989466493-ibbroio", | |
| 45 | - "type": "QRCODE", | |
| 46 | - "x": 32, | |
| 47 | - "y": 136, | |
| 48 | - "width": 80, | |
| 49 | - "height": 80, | |
| 50 | - "rotation": "horizontal", | |
| 51 | - "border": "none", | |
| 52 | - "config": { | |
| 53 | - "data": "https://example.com", | |
| 54 | - "errorLevel": "M" | |
| 55 | - } | |
| 56 | - }, | |
| 57 | - { | |
| 58 | - "id": "el-1773989469008-f1l39qj", | |
| 59 | - "type": "BARCODE", | |
| 60 | - "x": 0, | |
| 61 | - "y": 224, | |
| 62 | - "width": 160, | |
| 63 | - "height": 48, | |
| 64 | - "rotation": "horizontal", | |
| 65 | - "border": "none", | |
| 66 | - "config": { | |
| 67 | - "barcodeType": "CODE128", | |
| 68 | - "data": "123456789", | |
| 69 | - "showText": true, | |
| 70 | - "orientation": "horizontal" | |
| 71 | - } | |
| 72 | - }, | |
| 73 | - { | |
| 74 | - "id": "el-1773989473436-j7fdeh2", | |
| 75 | - "type": "BLANK", | |
| 76 | - "x": 32, | |
| 77 | - "y": 288, | |
| 78 | - "width": 48, | |
| 79 | - "height": 32, | |
| 80 | - "rotation": "horizontal", | |
| 81 | - "border": "none", | |
| 82 | - "config": {} | |
| 83 | - }, | |
| 84 | - { | |
| 85 | - "id": "el-1773989483341-ifwcyjj", | |
| 86 | - "type": "TEXT_PRICE", | |
| 87 | - "x": 152, | |
| 88 | - "y": 24, | |
| 89 | - "width": 80, | |
| 90 | - "height": 24, | |
| 91 | - "rotation": "horizontal", | |
| 92 | - "border": "none", | |
| 93 | - "config": { | |
| 94 | - "text": "0.00", | |
| 95 | - "prefix": "¥", | |
| 96 | - "decimal": 2, | |
| 97 | - "fontFamily": "Arial", | |
| 98 | - "fontSize": 14, | |
| 99 | - "fontWeight": "bold", | |
| 100 | - "textAlign": "right" | |
| 101 | - } | |
| 102 | - }, | |
| 103 | - { | |
| 104 | - "id": "el-1773989498031-e4d61j8", | |
| 105 | - "type": "IMAGE", | |
| 106 | - "x": 192, | |
| 107 | - "y": 56, | |
| 108 | - "width": 60, | |
| 109 | - "height": 60, | |
| 110 | - "rotation": "horizontal", | |
| 111 | - "border": "none", | |
| 112 | - "config": { | |
| 113 | - "src": "", | |
| 114 | - "scaleMode": "contain" | |
| 115 | - } | |
| 116 | - }, | |
| 117 | - { | |
| 118 | - "id": "el-1773989505076-1lxccx7", | |
| 119 | - "type": "TEXT_PRODUCT", | |
| 120 | - "x": 200, | |
| 121 | - "y": 136, | |
| 122 | - "width": 120, | |
| 123 | - "height": 24, | |
| 124 | - "rotation": "horizontal", | |
| 125 | - "border": "none", | |
| 126 | - "config": { | |
| 127 | - "text": "商品名", | |
| 128 | - "fontFamily": "Arial", | |
| 129 | - "fontSize": 14, | |
| 130 | - "fontWeight": "normal", | |
| 131 | - "textAlign": "left" | |
| 132 | - } | |
| 133 | - }, | |
| 134 | - { | |
| 135 | - "id": "el-1773989509805-ax3392v", | |
| 136 | - "type": "TEXT_STATIC", | |
| 137 | - "x": 192, | |
| 138 | - "y": 160, | |
| 139 | - "width": 120, | |
| 140 | - "height": 24, | |
| 141 | - "rotation": "horizontal", | |
| 142 | - "border": "none", | |
| 143 | - "config": { | |
| 144 | - "text": "文本", | |
| 145 | - "fontFamily": "Arial", | |
| 146 | - "fontSize": 14, | |
| 147 | - "fontWeight": "normal", | |
| 148 | - "textAlign": "left" | |
| 149 | - } | |
| 150 | - }, | |
| 151 | - { | |
| 152 | - "id": "el-1773989512993-xt8bg7q", | |
| 153 | - "type": "QRCODE", | |
| 154 | - "x": 184, | |
| 155 | - "y": 184, | |
| 156 | - "width": 80, | |
| 157 | - "height": 80, | |
| 158 | - "rotation": "horizontal", | |
| 159 | - "border": "none", | |
| 160 | - "config": { | |
| 161 | - "data": "https://example.com", | |
| 162 | - "errorLevel": "M" | |
| 163 | - } | |
| 164 | - }, | |
| 165 | - { | |
| 166 | - "id": "el-1773989525383-eji8p2s", | |
| 167 | - "type": "BARCODE", | |
| 168 | - "x": 0, | |
| 169 | - "y": 288, | |
| 170 | - "width": 160, | |
| 171 | - "height": 48, | |
| 172 | - "rotation": "horizontal", | |
| 173 | - "border": "none", | |
| 174 | - "config": { | |
| 175 | - "barcodeType": "CODE128", | |
| 176 | - "data": "123456789", | |
| 177 | - "showText": true, | |
| 178 | - "orientation": "horizontal" | |
| 179 | - } | |
| 180 | - }, | |
| 181 | - { | |
| 182 | - "id": "el-1773989540159-dr2avdf", | |
| 183 | - "type": "NUTRITION", | |
| 184 | - "x": 184, | |
| 185 | - "y": 280, | |
| 186 | - "width": 200, | |
| 187 | - "height": 120, | |
| 188 | - "rotation": "horizontal", | |
| 189 | - "border": "none", | |
| 190 | - "config": { | |
| 191 | - "calories": 120, | |
| 192 | - "fat": "5g", | |
| 193 | - "protein": "3g", | |
| 194 | - "carbs": "10g", | |
| 195 | - "layout": "standard" | |
| 196 | - } | |
| 197 | - }, | |
| 198 | - { | |
| 199 | - "id": "el-1773989549679-mcxrdnw", | |
| 200 | - "type": "TEXT_PRICE", | |
| 201 | - "x": 24, | |
| 202 | - "y": 352, | |
| 203 | - "width": 80, | |
| 204 | - "height": 24, | |
| 205 | - "rotation": "horizontal", | |
| 206 | - "border": "none", | |
| 207 | - "config": { | |
| 208 | - "text": "0.00", | |
| 209 | - "prefix": "¥", | |
| 210 | - "decimal": 2, | |
| 211 | - "fontFamily": "Arial", | |
| 212 | - "fontSize": 14, | |
| 213 | - "fontWeight": "bold", | |
| 214 | - "textAlign": "right" | |
| 215 | - } | |
| 216 | - } | |
| 217 | - ] | |
| 218 | -} | |
| 219 | 0 | \ No newline at end of file |
label-template-template-1773998862063 (1).json
0 → 100644
| 1 | +{ | |
| 2 | + "id": "template-1773998862063", | |
| 3 | + "name": "未命名模板", | |
| 4 | + "labelType": "PRICE", | |
| 5 | + "unit": "inch", | |
| 6 | + "width": 4, | |
| 7 | + "height": 2, | |
| 8 | + "appliedLocation": "ALL", | |
| 9 | + "showRuler": true, | |
| 10 | + "showGrid": true, | |
| 11 | + "elements": [ | |
| 12 | + { | |
| 13 | + "id": "el-1773998886036-34sylni", | |
| 14 | + "type": "TEXT_STATIC", | |
| 15 | + "x": 104, | |
| 16 | + "y": 16, | |
| 17 | + "width": 120, | |
| 18 | + "height": 24, | |
| 19 | + "rotation": "horizontal", | |
| 20 | + "border": "none", | |
| 21 | + "config": { | |
| 22 | + "text": "文本", | |
| 23 | + "fontFamily": "Arial", | |
| 24 | + "fontSize": 14, | |
| 25 | + "fontWeight": "normal", | |
| 26 | + "textAlign": "center" | |
| 27 | + } | |
| 28 | + }, | |
| 29 | + { | |
| 30 | + "id": "el-1773998909568-4jjwdx7", | |
| 31 | + "type": "TEXT_PRODUCT", | |
| 32 | + "x": 96, | |
| 33 | + "y": 128, | |
| 34 | + "width": 120, | |
| 35 | + "height": 24, | |
| 36 | + "rotation": "horizontal", | |
| 37 | + "border": "none", | |
| 38 | + "config": { | |
| 39 | + "text": "商品名", | |
| 40 | + "fontFamily": "Arial", | |
| 41 | + "fontSize": 14, | |
| 42 | + "fontWeight": "normal", | |
| 43 | + "textAlign": "left" | |
| 44 | + } | |
| 45 | + }, | |
| 46 | + { | |
| 47 | + "id": "el-1773998913096-cgabpx1", | |
| 48 | + "type": "TEXT_STATIC", | |
| 49 | + "x": 88, | |
| 50 | + "y": 152, | |
| 51 | + "width": 120, | |
| 52 | + "height": 24, | |
| 53 | + "rotation": "horizontal", | |
| 54 | + "border": "none", | |
| 55 | + "config": { | |
| 56 | + "text": "文本", | |
| 57 | + "fontFamily": "Arial", | |
| 58 | + "fontSize": 14, | |
| 59 | + "fontWeight": "normal", | |
| 60 | + "textAlign": "left" | |
| 61 | + } | |
| 62 | + }, | |
| 63 | + { | |
| 64 | + "id": "el-1773999052674-uzocw1j", | |
| 65 | + "type": "QRCODE", | |
| 66 | + "x": 128, | |
| 67 | + "y": 40, | |
| 68 | + "width": 80, | |
| 69 | + "height": 80, | |
| 70 | + "rotation": "horizontal", | |
| 71 | + "border": "none", | |
| 72 | + "config": { | |
| 73 | + "data": "12341千问请问抛弃我", | |
| 74 | + "errorLevel": "M" | |
| 75 | + } | |
| 76 | + }, | |
| 77 | + { | |
| 78 | + "id": "el-1773999078958-5tgoru7", | |
| 79 | + "type": "BARCODE", | |
| 80 | + "x": 208, | |
| 81 | + "y": 128, | |
| 82 | + "width": 160, | |
| 83 | + "height": 48, | |
| 84 | + "rotation": "horizontal", | |
| 85 | + "border": "none", | |
| 86 | + "config": { | |
| 87 | + "barcodeType": "CODE128", | |
| 88 | + "data": "14124151231", | |
| 89 | + "showText": true, | |
| 90 | + "orientation": "horizontal" | |
| 91 | + } | |
| 92 | + } | |
| 93 | + ] | |
| 94 | +} | |
| 0 | 95 | \ No newline at end of file | ... | ... |
标签模块接口对接说明(1).md
0 → 100644
| 1 | +## 概述 | |
| 2 | + | |
| 3 | +美国版后端采用 ABP 动态接口(ConventionalControllers),宿主统一前缀为 `api/app`。 | |
| 4 | +Swagger 地址: | |
| 5 | + | |
| 6 | +- `http://localhost:19001/swagger` | |
| 7 | + | |
| 8 | +说明: | |
| 9 | +- 接口最终 URL 以 Swagger 展示为准(可在 Swagger 里搜索 `LabelCategory / LabelType / LabelMultipleOption / LabelTemplate / Label`)。 | |
| 10 | +- 本模块后端接口以各 AppService 的方法签名自动暴露。 | |
| 11 | +- 返回分页统一包含 `PageIndex / PageSize / TotalCount / TotalPages / Items`。 | |
| 12 | + | |
| 13 | +--- | |
| 14 | + | |
| 15 | +## Swagger 中如何找到 | |
| 16 | + | |
| 17 | +1. 启动后端宿主(`Yi.Abp.Web`),端口 `19001`。 | |
| 18 | +2. 打开 `http://localhost:19001/swagger`。 | |
| 19 | +3. 在接口分组里搜索以下关键词之一: | |
| 20 | + - `label-category` | |
| 21 | + - `label-type` | |
| 22 | + - `label-multiple-option` | |
| 23 | + - `label-template` | |
| 24 | + - `label` | |
| 25 | + | |
| 26 | +--- | |
| 27 | + | |
| 28 | +## 接口 1:Label Categories(标签分类) | |
| 29 | + | |
| 30 | +### 1.1 分页列表 | |
| 31 | + | |
| 32 | +方法:`GET /api/app/label-category` | |
| 33 | + | |
| 34 | +入参(`LabelCategoryGetListInputVo`,查询参数): | |
| 35 | + | |
| 36 | +- `skipCount`(int) | |
| 37 | +- `maxResultCount`(int) | |
| 38 | +- `sorting`(string,可选) | |
| 39 | +- `keyword`(string,可选) | |
| 40 | +- `state`(boolean,可选) | |
| 41 | + | |
| 42 | +示例(查询参数): | |
| 43 | + | |
| 44 | +```json | |
| 45 | +{ | |
| 46 | + "skipCount": 0, | |
| 47 | + "maxResultCount": 10, | |
| 48 | + "keyword": "Prep", | |
| 49 | + "state": true | |
| 50 | +} | |
| 51 | +``` | |
| 52 | + | |
| 53 | +### 1.2 详情 | |
| 54 | + | |
| 55 | +方法:`GET /api/app/label-category/{id}` | |
| 56 | + | |
| 57 | +入参: | |
| 58 | + | |
| 59 | +- `id`:分类 Id(字符串) | |
| 60 | + | |
| 61 | +### 1.3 新增 | |
| 62 | + | |
| 63 | +方法:`POST /api/app/label-category` | |
| 64 | + | |
| 65 | +入参(Body:`LabelCategoryCreateInputVo`): | |
| 66 | + | |
| 67 | +```json | |
| 68 | +{ | |
| 69 | + "categoryCode": "CAT_PREP", | |
| 70 | + "categoryName": "Prep", | |
| 71 | + "categoryPhotoUrl": "https://cdn.example.com/cat-prep.png", | |
| 72 | + "state": true, | |
| 73 | + "orderNum": 1 | |
| 74 | +} | |
| 75 | +``` | |
| 76 | + | |
| 77 | +### 1.4 编辑 | |
| 78 | + | |
| 79 | +方法:`PUT /api/app/label-category/{id}` | |
| 80 | + | |
| 81 | +入参(Body:`LabelCategoryUpdateInputVo`,字段同创建): | |
| 82 | + | |
| 83 | +```json | |
| 84 | +{ | |
| 85 | + "categoryCode": "CAT_PREP", | |
| 86 | + "categoryName": "Prep", | |
| 87 | + "categoryPhotoUrl": null, | |
| 88 | + "state": true, | |
| 89 | + "orderNum": 2 | |
| 90 | +} | |
| 91 | +``` | |
| 92 | + | |
| 93 | +### 1.5 删除(逻辑删除) | |
| 94 | + | |
| 95 | +方法:`DELETE /api/app/label-category/{id}` | |
| 96 | + | |
| 97 | +入参: | |
| 98 | + | |
| 99 | +- `id`:分类 Id(字符串) | |
| 100 | + | |
| 101 | +删除校验: | |
| 102 | +- 若该分类已被 `fl_label` 引用,则抛出友好错误,禁止删除。 | |
| 103 | + | |
| 104 | +--- | |
| 105 | + | |
| 106 | +## 接口 2:Label Types(标签类型) | |
| 107 | + | |
| 108 | +### 2.1 分页列表 | |
| 109 | + | |
| 110 | +方法:`GET /api/app/label-type` | |
| 111 | + | |
| 112 | +入参(`LabelTypeGetListInputVo`,查询参数): | |
| 113 | + | |
| 114 | +```json | |
| 115 | +{ | |
| 116 | + "skipCount": 0, | |
| 117 | + "maxResultCount": 10, | |
| 118 | + "keyword": "Defrost", | |
| 119 | + "state": true | |
| 120 | +} | |
| 121 | +``` | |
| 122 | + | |
| 123 | +### 2.2 详情 | |
| 124 | + | |
| 125 | +方法:`GET /api/app/label-type/{id}` | |
| 126 | + | |
| 127 | +入参: | |
| 128 | + | |
| 129 | +- `id`:类型 Id(字符串) | |
| 130 | + | |
| 131 | +### 2.3 新增 | |
| 132 | + | |
| 133 | +方法:`POST /api/app/label-type` | |
| 134 | + | |
| 135 | +入参(Body:`LabelTypeCreateInputVo`): | |
| 136 | + | |
| 137 | +```json | |
| 138 | +{ | |
| 139 | + "typeCode": "TYPE_DEFROST", | |
| 140 | + "typeName": "Defrost", | |
| 141 | + "state": true, | |
| 142 | + "orderNum": 1 | |
| 143 | +} | |
| 144 | +``` | |
| 145 | + | |
| 146 | +### 2.4 编辑 | |
| 147 | + | |
| 148 | +方法:`PUT /api/app/label-type/{id}` | |
| 149 | + | |
| 150 | +入参(Body:`LabelTypeUpdateInputVo`,字段同创建): | |
| 151 | + | |
| 152 | +```json | |
| 153 | +{ | |
| 154 | + "typeCode": "TYPE_DEFROST", | |
| 155 | + "typeName": "Defrost", | |
| 156 | + "state": true, | |
| 157 | + "orderNum": 2 | |
| 158 | +} | |
| 159 | +``` | |
| 160 | + | |
| 161 | +### 2.5 删除(逻辑删除) | |
| 162 | + | |
| 163 | +方法:`DELETE /api/app/label-type/{id}` | |
| 164 | + | |
| 165 | +删除校验: | |
| 166 | +- 若该类型已被 `fl_label` 引用,则禁止删除。 | |
| 167 | + | |
| 168 | +--- | |
| 169 | + | |
| 170 | +## 接口 3:Multiple Options(多选项字典) | |
| 171 | + | |
| 172 | +### 3.1 分页列表 | |
| 173 | + | |
| 174 | +方法:`GET /api/app/label-multiple-option` | |
| 175 | + | |
| 176 | +入参(`LabelMultipleOptionGetListInputVo`,查询参数): | |
| 177 | + | |
| 178 | +```json | |
| 179 | +{ | |
| 180 | + "skipCount": 0, | |
| 181 | + "maxResultCount": 10, | |
| 182 | + "keyword": "Allergens", | |
| 183 | + "state": true | |
| 184 | +} | |
| 185 | +``` | |
| 186 | + | |
| 187 | +### 3.2 详情 | |
| 188 | + | |
| 189 | +方法:`GET /api/app/label-multiple-option/{id}` | |
| 190 | + | |
| 191 | +入参: | |
| 192 | + | |
| 193 | +- `id`:多选项 Id(字符串) | |
| 194 | + | |
| 195 | +### 3.3 新增 | |
| 196 | + | |
| 197 | +方法:`POST /api/app/label-multiple-option` | |
| 198 | + | |
| 199 | +入参(Body:`LabelMultipleOptionCreateInputVo`): | |
| 200 | + | |
| 201 | +说明:`optionValuesJson` 为 **JSON 字符串**,值为 string 数组的序列化结果(与库表/后端 DTO 一致),例如 `["Peanuts","Dairy"]` 对应字符串 `"[\"Peanuts\",\"Dairy\"]"`。 | |
| 202 | + | |
| 203 | +```json | |
| 204 | +{ | |
| 205 | + "optionCode": "OPT_ALLERGENS", | |
| 206 | + "optionName": "Allergens", | |
| 207 | + "optionValuesJson": "[\"Peanuts\",\"Dairy\",\"Gluten\",\"Soy\"]", | |
| 208 | + "state": true, | |
| 209 | + "orderNum": 1 | |
| 210 | +} | |
| 211 | +``` | |
| 212 | + | |
| 213 | +### 3.4 编辑 | |
| 214 | + | |
| 215 | +方法:`PUT /api/app/label-multiple-option/{id}` | |
| 216 | + | |
| 217 | +入参(Body:`LabelMultipleOptionUpdateInputVo`,字段同创建): | |
| 218 | + | |
| 219 | +```json | |
| 220 | +{ | |
| 221 | + "optionCode": "OPT_ALLERGENS", | |
| 222 | + "optionName": "Allergens", | |
| 223 | + "optionValuesJson": "[\"Peanuts\",\"Dairy\"]", | |
| 224 | + "state": true, | |
| 225 | + "orderNum": 2 | |
| 226 | +} | |
| 227 | +``` | |
| 228 | + | |
| 229 | +### 3.5 删除(逻辑删除) | |
| 230 | + | |
| 231 | +方法:`DELETE /api/app/label-multiple-option/{id}` | |
| 232 | + | |
| 233 | +--- | |
| 234 | + | |
| 235 | +## 接口 4:Label Templates(标签模板) | |
| 236 | + | |
| 237 | +说明: | |
| 238 | +- 模板标识入参 `id` 使用 `fl_label_template.TemplateCode`。 | |
| 239 | +- 创建/编辑的 Body 字段名对齐你前端 editor JSON(`id/name/appliedLocation/elements/config`)。 | |
| 240 | + | |
| 241 | +### 4.1 分页列表 | |
| 242 | + | |
| 243 | +方法:`GET /api/app/label-template` | |
| 244 | + | |
| 245 | +入参(`LabelTemplateGetListInputVo`,查询参数): | |
| 246 | + | |
| 247 | +```json | |
| 248 | +{ | |
| 249 | + "skipCount": 0, | |
| 250 | + "maxResultCount": 10, | |
| 251 | + "keyword": "测试模板", | |
| 252 | + "locationId": "11111111-1111-1111-1111-111111111111", | |
| 253 | + "labelType": "PRICE", | |
| 254 | + "state": true | |
| 255 | +} | |
| 256 | +``` | |
| 257 | + | |
| 258 | +### 4.2 详情 | |
| 259 | + | |
| 260 | +方法:`GET /api/app/label-template/{id}` | |
| 261 | + | |
| 262 | +入参: | |
| 263 | + | |
| 264 | +- `id`:模板编码 `TemplateCode`(字符串) | |
| 265 | + | |
| 266 | +### 4.3 新增模板 | |
| 267 | + | |
| 268 | +方法:`POST /api/app/label-template` | |
| 269 | + | |
| 270 | +入参(Body:`LabelTemplateCreateInputVo`): | |
| 271 | + | |
| 272 | +```json | |
| 273 | +{ | |
| 274 | + "id": "TPL_TEST_001", | |
| 275 | + "name": "测试模板-价格签(4x6)", | |
| 276 | + "labelType": "PRICE", | |
| 277 | + "unit": "inch", | |
| 278 | + "width": 4, | |
| 279 | + "height": 6, | |
| 280 | + "appliedLocation": "ALL", | |
| 281 | + "showRuler": true, | |
| 282 | + "showGrid": true, | |
| 283 | + "state": true, | |
| 284 | + "elements": [ | |
| 285 | + { | |
| 286 | + "id": "el-fixed-title", | |
| 287 | + "type": "TEXT_STATIC", | |
| 288 | + "x": 32, | |
| 289 | + "y": 24, | |
| 290 | + "width": 160, | |
| 291 | + "height": 24, | |
| 292 | + "rotation": "horizontal", | |
| 293 | + "border": "none", | |
| 294 | + "zIndex": 1, | |
| 295 | + "orderNum": 1, | |
| 296 | + "valueSourceType": "FIXED", | |
| 297 | + "isRequiredInput": false, | |
| 298 | + "config": { | |
| 299 | + "text": "商品名", | |
| 300 | + "fontFamily": "Arial", | |
| 301 | + "fontSize": 14, | |
| 302 | + "fontWeight": "bold", | |
| 303 | + "textAlign": "left" | |
| 304 | + } | |
| 305 | + } | |
| 306 | + ], | |
| 307 | + "appliedLocationIds": [] | |
| 308 | +} | |
| 309 | +``` | |
| 310 | + | |
| 311 | +说明: | |
| 312 | +- 当 `appliedLocation=SPECIFIED` 时,`appliedLocationIds` 必须至少选择一个门店。 | |
| 313 | + | |
| 314 | +### 4.4 编辑模板 | |
| 315 | + | |
| 316 | +方法:`PUT /api/app/label-template/{id}` | |
| 317 | + | |
| 318 | +入参: | |
| 319 | +- Path:`id` 是当前模板编码(TemplateCode) | |
| 320 | +- Body:字段同新增(`id/name/elements/...`) | |
| 321 | + | |
| 322 | +示例(编辑:同样字段,appliedLocation 切到 SPECIFIED): | |
| 323 | + | |
| 324 | +```json | |
| 325 | +{ | |
| 326 | + "id": "TPL_TEST_001", | |
| 327 | + "name": "测试模板-价格签(4x6) v2", | |
| 328 | + "labelType": "PRICE", | |
| 329 | + "unit": "inch", | |
| 330 | + "width": 4, | |
| 331 | + "height": 6, | |
| 332 | + "appliedLocation": "SPECIFIED", | |
| 333 | + "showRuler": true, | |
| 334 | + "showGrid": true, | |
| 335 | + "state": true, | |
| 336 | + "elements": [], | |
| 337 | + "appliedLocationIds": ["11111111-1111-1111-1111-111111111111"] | |
| 338 | +} | |
| 339 | +``` | |
| 340 | + | |
| 341 | +版本: | |
| 342 | +- `VersionNo` 会在编辑时自动 `+1`。 | |
| 343 | +- `elements` 会按传入内容全量重建。 | |
| 344 | + | |
| 345 | +### 4.5 删除(逻辑删除) | |
| 346 | + | |
| 347 | +方法:`DELETE /api/app/label-template/{id}` | |
| 348 | + | |
| 349 | +入参: | |
| 350 | +- `id`:模板编码 `TemplateCode` | |
| 351 | + | |
| 352 | +删除校验: | |
| 353 | +- 若该模板已被 `fl_label` 引用,则禁止删除。 | |
| 354 | + | |
| 355 | +--- | |
| 356 | + | |
| 357 | +## 接口 5:Labels(按产品展示多个标签) | |
| 358 | + | |
| 359 | +说明: | |
| 360 | +- 列表接口按 `ProductId` 查询,一个产品会对应多条标签记录。 | |
| 361 | +- 标签详情/编辑/删除的 `id` 使用 `fl_label.LabelCode`。 | |
| 362 | + | |
| 363 | +### 5.1 分页列表(按产品) | |
| 364 | + | |
| 365 | +方法:`GET /api/app/label` | |
| 366 | + | |
| 367 | +入参(`LabelGetListInputVo`,查询参数): | |
| 368 | + | |
| 369 | +```json | |
| 370 | +{ | |
| 371 | + "skipCount": 0, | |
| 372 | + "maxResultCount": 10, | |
| 373 | + "sorting": "", | |
| 374 | + "keyword": "早餐", | |
| 375 | + "locationId": "11111111-1111-1111-1111-111111111111", | |
| 376 | + "productId": "22222222-2222-2222-2222-222222222222", | |
| 377 | + "labelCategoryId": "33333333-3333-3333-3333-333333333333", | |
| 378 | + "labelTypeId": "44444444-4444-4444-4444-444444444444", | |
| 379 | + "templateCode": "TPL_TEST_001", | |
| 380 | + "state": true | |
| 381 | +} | |
| 382 | +``` | |
| 383 | + | |
| 384 | +### 5.2 详情 | |
| 385 | + | |
| 386 | +方法:`GET /api/app/label/{id}` | |
| 387 | + | |
| 388 | +入参: | |
| 389 | +- `id`:标签编码 `LabelCode` | |
| 390 | + | |
| 391 | +返回: | |
| 392 | +- `productIds`:该标签绑定的产品Id 列表 | |
| 393 | + | |
| 394 | +### 5.3 新增标签 | |
| 395 | + | |
| 396 | +方法:`POST /api/app/label` | |
| 397 | + | |
| 398 | +入参(Body:`LabelCreateInputVo`): | |
| 399 | + | |
| 400 | +```json | |
| 401 | +{ | |
| 402 | + "labelCode": "LBL_TEST_001", | |
| 403 | + "labelName": "早餐标签", | |
| 404 | + "templateCode": "TPL_TEST_001", | |
| 405 | + "locationId": "11111111-1111-1111-1111-111111111111", | |
| 406 | + "labelCategoryId": "33333333-3333-3333-3333-333333333333", | |
| 407 | + "labelTypeId": "44444444-4444-4444-4444-444444444444", | |
| 408 | + "productIds": ["22222222-2222-2222-2222-222222222222"], | |
| 409 | + "labelInfoJson": { "note": "测试标签1" }, | |
| 410 | + "state": true | |
| 411 | +} | |
| 412 | +``` | |
| 413 | + | |
| 414 | +校验: | |
| 415 | +- `productIds` 至少 1 个 | |
| 416 | +- `templateCode/locationId/labelCategoryId/labelTypeId` 不能为空 | |
| 417 | + | |
| 418 | +### 5.4 编辑标签 | |
| 419 | + | |
| 420 | +方法:`PUT /api/app/label/{id}` | |
| 421 | + | |
| 422 | +入参: | |
| 423 | +- Path:`id` 为当前标签编码 `LabelCode` | |
| 424 | +- Body:字段同创建(`LabelUpdateInputVo`) | |
| 425 | + | |
| 426 | +```json | |
| 427 | +{ | |
| 428 | + "labelName": "早餐标签 v2", | |
| 429 | + "templateCode": "TPL_TEST_001", | |
| 430 | + "locationId": "11111111-1111-1111-1111-111111111111", | |
| 431 | + "labelCategoryId": "33333333-3333-3333-3333-333333333333", | |
| 432 | + "labelTypeId": "44444444-4444-4444-4444-444444444444", | |
| 433 | + "productIds": ["22222222-2222-2222-2222-222222222222"], | |
| 434 | + "labelInfoJson": { "note": "测试标签1 v2" }, | |
| 435 | + "state": true | |
| 436 | +} | |
| 437 | +``` | |
| 438 | + | |
| 439 | +关联维护: | |
| 440 | +- `fl_label_product` 会按新 `productIds` 重建。 | |
| 441 | + | |
| 442 | +### 5.5 删除标签(逻辑删除) | |
| 443 | + | |
| 444 | +方法:`DELETE /api/app/label/{id}` | |
| 445 | + | |
| 446 | +入参: | |
| 447 | +- `id`:标签编码 `LabelCode` | |
| 448 | + | |
| 449 | +删除行为: | |
| 450 | +- 逻辑删除 `fl_label` | |
| 451 | +- 删除该标签对应的 `fl_label_product` 关联 | |
| 452 | + | |
| 453 | +--- | |
| 454 | +## 接口 6:Products(产品) | |
| 455 | + | |
| 456 | +说明: | |
| 457 | +- 产品表:`fl_product` | |
| 458 | +- 删除为逻辑删除:`IsDeleted = true` | |
| 459 | + | |
| 460 | +### 6.1 分页列表 | |
| 461 | + | |
| 462 | +方法:`GET /api/app/product` | |
| 463 | + | |
| 464 | +入参(`ProductGetListInputVo`,查询参数): | |
| 465 | +```json | |
| 466 | +{ | |
| 467 | + "skipCount": 0, | |
| 468 | + "maxResultCount": 10, | |
| 469 | + "sorting": "", | |
| 470 | + "keyword": "Chicken", | |
| 471 | + "state": true | |
| 472 | +} | |
| 473 | +``` | |
| 474 | + | |
| 475 | +### 6.2 详情 | |
| 476 | + | |
| 477 | +方法:`GET /api/app/product/{id}` | |
| 478 | + | |
| 479 | +入参: | |
| 480 | +- `id`:产品Id(`fl_product.Id`) | |
| 481 | + | |
| 482 | +### 6.3 新增产品 | |
| 483 | + | |
| 484 | +方法:`POST /api/app/product` | |
| 485 | + | |
| 486 | +入参(Body:`ProductCreateInputVo`): | |
| 487 | +```json | |
| 488 | +{ | |
| 489 | + "productCode": "PRD_TEST_001", | |
| 490 | + "productName": "Chicken", | |
| 491 | + "categoryName": "Meat", | |
| 492 | + "productImageUrl": "https://example.com/img.png", | |
| 493 | + "state": true | |
| 494 | +} | |
| 495 | +``` | |
| 496 | + | |
| 497 | +校验: | |
| 498 | +- `productCode/productName` 不能为空 | |
| 499 | +- `productCode` 不能与未删除的数据重复 | |
| 500 | + | |
| 501 | +### 6.4 编辑产品 | |
| 502 | + | |
| 503 | +方法:`PUT /api/app/product/{id}` | |
| 504 | + | |
| 505 | +入参: | |
| 506 | +- Path:`id` 为当前产品Id(`fl_product.Id`) | |
| 507 | +- Body:字段同新增(`ProductUpdateInputVo`) | |
| 508 | + | |
| 509 | +### 6.5 删除(逻辑删除) | |
| 510 | + | |
| 511 | +方法:`DELETE /api/app/product/{id}` | |
| 512 | + | |
| 513 | +入参: | |
| 514 | +- `id`:产品Id | |
| 515 | + | |
| 516 | +--- | |
| 517 | +## 接口 7:Product-Location(门店-产品关联) | |
| 518 | + | |
| 519 | +说明: | |
| 520 | +- 关联表:`fl_location_product` | |
| 521 | +- 关联按门店进行批量替换: | |
| 522 | + - `Create`:在门店下新增未存在的 product 关联 | |
| 523 | + - `Update`:替换该门店下全部关联(先删后建) | |
| 524 | + - `Delete`:删除该门店下全部关联 | |
| 525 | + | |
| 526 | +### 7.1 分页列表 | |
| 527 | + | |
| 528 | +方法:`GET /api/app/product-location` | |
| 529 | + | |
| 530 | +入参(`ProductLocationGetListInputVo`,查询参数): | |
| 531 | +```json | |
| 532 | +{ | |
| 533 | + "skipCount": 0, | |
| 534 | + "maxResultCount": 10, | |
| 535 | + "sorting": "", | |
| 536 | + "locationId": "11111111-1111-1111-1111-111111111111", | |
| 537 | + "productId": "22222222-2222-2222-2222-222222222222" | |
| 538 | +} | |
| 539 | +``` | |
| 540 | + | |
| 541 | +### 7.2 获取门店下全部产品 | |
| 542 | + | |
| 543 | +方法:`GET /api/app/product-location/{id}` | |
| 544 | + | |
| 545 | +入参: | |
| 546 | +- `id`:门店Id(`location.Id`,string 表示) | |
| 547 | + | |
| 548 | +返回: | |
| 549 | +- 门店Id + 该门店关联的产品列表 | |
| 550 | + | |
| 551 | +### 7.3 新增/建立门店关联 | |
| 552 | + | |
| 553 | +方法:`POST /api/app/product-location` | |
| 554 | + | |
| 555 | +入参(Body:`ProductLocationCreateInputVo`): | |
| 556 | +```json | |
| 557 | +{ | |
| 558 | + "locationId": "11111111-1111-1111-1111-111111111111", | |
| 559 | + "productIds": ["22222222-2222-2222-2222-222222222222"] | |
| 560 | +} | |
| 561 | +``` | |
| 562 | + | |
| 563 | +校验: | |
| 564 | +- `locationId` 对应门店必须存在 | |
| 565 | +- `productIds` 必须都存在于 `fl_product` 且未删除 | |
| 566 | + | |
| 567 | +### 7.4 编辑/替换门店关联 | |
| 568 | + | |
| 569 | +方法:`PUT /api/app/product-location/{id}` | |
| 570 | + | |
| 571 | +入参: | |
| 572 | +- Path:`id` 为门店Id | |
| 573 | +- Body:`ProductLocationUpdateInputVo` | |
| 574 | +```json | |
| 575 | +{ | |
| 576 | + "productIds": ["22222222-2222-2222-2222-222222222222"] | |
| 577 | +} | |
| 578 | +``` | |
| 579 | + | |
| 580 | +### 7.5 删除门店关联(按门店删除全部) | |
| 581 | + | |
| 582 | +方法:`DELETE /api/app/product-location/{id}` | |
| 583 | + | |
| 584 | +入参: | |
| 585 | +- `id`:门店Id | |
| 586 | + | ... | ... |
泰额版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/LabelCanvas.tsx
| ... | ... | @@ -1195,7 +1195,7 @@ export function LabelCanvas({ |
| 1195 | 1195 | ); |
| 1196 | 1196 | } |
| 1197 | 1197 | |
| 1198 | -/** 仅用于预览:无网格、无标尺、无拖拽,按比例缩放 */ | |
| 1198 | +/** Preview only: no grid, no rulers, no drag; scale to fit. */ | |
| 1199 | 1199 | export function LabelPreviewOnly({ |
| 1200 | 1200 | template, |
| 1201 | 1201 | maxWidth = 480, | ... | ... |
美国版/Food Labeling Management Platform/.env.local
美国版/Food Labeling Management Platform/build/assets/index-TU5tblcP.js deleted
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/index-hS5-bp4f.js
0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/index.html
| ... | ... | @@ -5,7 +5,7 @@ |
| 5 | 5 | <meta charset="UTF-8" /> |
| 6 | 6 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 7 | 7 | <title>Food Labeling Management Platform</title> |
| 8 | - <script type="module" crossorigin src="/assets/index-TU5tblcP.js"></script> | |
| 8 | + <script type="module" crossorigin src="/assets/index-hS5-bp4f.js"></script> | |
| 9 | 9 | <link rel="stylesheet" crossorigin href="/assets/index-DKXCW1Pt.css"> |
| 10 | 10 | </head> |
| 11 | 11 | ... | ... |
美国版/Food Labeling Management Platform/src/components/labels/LabelCategoriesView.tsx
| 1 | -import React from 'react'; | |
| 1 | +import React, { useEffect, useMemo, useRef, useState } from 'react'; | |
| 2 | 2 | import { |
| 3 | 3 | Table, |
| 4 | 4 | TableBody, |
| ... | ... | @@ -16,82 +16,666 @@ import { |
| 16 | 16 | SelectTrigger, |
| 17 | 17 | SelectValue, |
| 18 | 18 | } from "../ui/select"; |
| 19 | -import { Plus } from "lucide-react"; | |
| 19 | +import { | |
| 20 | + Dialog, | |
| 21 | + DialogContent, | |
| 22 | + DialogDescription, | |
| 23 | + DialogFooter, | |
| 24 | + DialogHeader, | |
| 25 | + DialogTitle, | |
| 26 | +} from "../ui/dialog"; | |
| 27 | +import { Label } from "../ui/label"; | |
| 28 | +import { Switch } from "../ui/switch"; | |
| 29 | +import { Badge } from "../ui/badge"; | |
| 30 | +import { Plus, Edit, MoreHorizontal } from "lucide-react"; | |
| 31 | +import { toast } from "sonner"; | |
| 32 | +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | |
| 33 | +import { | |
| 34 | + Pagination, | |
| 35 | + PaginationContent, | |
| 36 | + PaginationItem, | |
| 37 | + PaginationLink, | |
| 38 | + PaginationNext, | |
| 39 | + PaginationPrevious, | |
| 40 | +} from "../ui/pagination"; | |
| 41 | +import { | |
| 42 | + getLabelCategories, | |
| 43 | + getLabelCategory, | |
| 44 | + createLabelCategory, | |
| 45 | + updateLabelCategory, | |
| 46 | + deleteLabelCategory, | |
| 47 | +} from "../../services/labelCategoryService"; | |
| 48 | +import type { | |
| 49 | + LabelCategoryDto, | |
| 50 | + LabelCategoryCreateInput, | |
| 51 | + LabelCategoryUpdateInput, | |
| 52 | +} from "../../types/labelCategory"; | |
| 53 | + | |
| 54 | +function toDisplay(v: string | null | undefined): string { | |
| 55 | + const s = (v ?? "").trim(); | |
| 56 | + return s ? s : "None"; | |
| 57 | +} | |
| 20 | 58 | |
| 21 | 59 | export function LabelCategoriesView() { |
| 22 | - const categories = [ | |
| 23 | - { | |
| 24 | - id: 1, | |
| 25 | - category: 'Prep', | |
| 26 | - count: 54, | |
| 27 | - photo: 'XXX', | |
| 28 | - lastEdited: '2025.12.03.11:45', | |
| 29 | - }, | |
| 30 | - { | |
| 31 | - id: 2, | |
| 32 | - category: 'Green', | |
| 33 | - count: 33, | |
| 34 | - photo: 'XXX', | |
| 35 | - lastEdited: '2025.12.03.11:45', | |
| 36 | - }, | |
| 37 | - { | |
| 38 | - id: 3, | |
| 39 | - category: 'Red', | |
| 40 | - count: 44, | |
| 41 | - photo: 'XXX', | |
| 42 | - lastEdited: '2025.12.03.11:45', | |
| 43 | - }, | |
| 44 | - ]; | |
| 60 | + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); | |
| 61 | + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); | |
| 62 | + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); | |
| 63 | + const [editingCategory, setEditingCategory] = useState<LabelCategoryDto | null>(null); | |
| 64 | + const [deletingCategory, setDeletingCategory] = useState<LabelCategoryDto | null>(null); | |
| 65 | + const [categories, setCategories] = useState<LabelCategoryDto[]>([]); | |
| 66 | + const [loading, setLoading] = useState(false); | |
| 67 | + const [total, setTotal] = useState(0); | |
| 68 | + const [refreshSeq, setRefreshSeq] = useState(0); | |
| 69 | + const [actionsOpenForId, setActionsOpenForId] = useState<string | null>(null); | |
| 70 | + | |
| 71 | + const [keyword, setKeyword] = useState(""); | |
| 72 | + const [stateFilter, setStateFilter] = useState<string>("all"); | |
| 73 | + | |
| 74 | + const [pageIndex, setPageIndex] = useState(1); | |
| 75 | + const [pageSize, setPageSize] = useState(10); | |
| 76 | + | |
| 77 | + const abortRef = useRef<AbortController | null>(null); | |
| 78 | + const keywordTimerRef = useRef<number | null>(null); | |
| 79 | + const [debouncedKeyword, setDebouncedKeyword] = useState(""); | |
| 80 | + | |
| 81 | + useEffect(() => { | |
| 82 | + if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); | |
| 83 | + keywordTimerRef.current = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300); | |
| 84 | + return () => { | |
| 85 | + if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); | |
| 86 | + }; | |
| 87 | + }, [keyword]); | |
| 88 | + | |
| 89 | + const totalPages = Math.max(1, Math.ceil(total / pageSize)); | |
| 90 | + | |
| 91 | + useEffect(() => { | |
| 92 | + setPageIndex(1); | |
| 93 | + }, [debouncedKeyword, stateFilter, pageSize]); | |
| 94 | + | |
| 95 | + useEffect(() => { | |
| 96 | + const run = async () => { | |
| 97 | + abortRef.current?.abort(); | |
| 98 | + const ac = new AbortController(); | |
| 99 | + abortRef.current = ac; | |
| 100 | + | |
| 101 | + setLoading(true); | |
| 102 | + try { | |
| 103 | + // skipCount 从 0 开始,前端分页从 1 开始,需要转换 | |
| 104 | + const skipCount = (pageIndex - 1) * pageSize; | |
| 105 | + const res = await getLabelCategories( | |
| 106 | + { | |
| 107 | + skipCount, | |
| 108 | + maxResultCount: pageSize, | |
| 109 | + keyword: debouncedKeyword || undefined, | |
| 110 | + state: stateFilter === "all" ? undefined : stateFilter === "true", | |
| 111 | + }, | |
| 112 | + ac.signal, | |
| 113 | + ); | |
| 114 | + | |
| 115 | + setCategories(res.items ?? []); | |
| 116 | + setTotal(res.totalCount ?? 0); | |
| 117 | + } catch (e: any) { | |
| 118 | + if (e?.name === "AbortError") return; | |
| 119 | + toast.error("Failed to load label categories.", { | |
| 120 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 121 | + }); | |
| 122 | + setCategories([]); | |
| 123 | + setTotal(0); | |
| 124 | + } finally { | |
| 125 | + setLoading(false); | |
| 126 | + } | |
| 127 | + }; | |
| 128 | + | |
| 129 | + run(); | |
| 130 | + return () => abortRef.current?.abort(); | |
| 131 | + }, [debouncedKeyword, stateFilter, pageIndex, pageSize, refreshSeq]); | |
| 132 | + | |
| 133 | + const refreshList = () => setRefreshSeq((x) => x + 1); | |
| 134 | + | |
| 135 | + const openEdit = (cat: LabelCategoryDto) => { | |
| 136 | + setActionsOpenForId(null); | |
| 137 | + setEditingCategory(cat); | |
| 138 | + setIsEditDialogOpen(true); | |
| 139 | + }; | |
| 140 | + | |
| 141 | + const openDelete = (cat: LabelCategoryDto) => { | |
| 142 | + setActionsOpenForId(null); | |
| 143 | + setDeletingCategory(cat); | |
| 144 | + setIsDeleteDialogOpen(true); | |
| 145 | + }; | |
| 45 | 146 | |
| 46 | 147 | return ( |
| 47 | - <div className="space-y-6"> | |
| 48 | - {/* Search, Location, New Label Category - single row */} | |
| 49 | - <div className="flex flex-nowrap items-center gap-3"> | |
| 50 | - <Input | |
| 51 | - placeholder="Search" | |
| 52 | - style={{ height: 40, boxSizing: 'border-box' }} | |
| 53 | - className="bg-white border border-gray-300 rounded-md w-40 shrink-0 placeholder:text-gray-500" | |
| 54 | - /> | |
| 55 | - <span className="text-sm font-medium text-gray-900 whitespace-nowrap shrink-0">Search</span> | |
| 56 | - <Select defaultValue="all"> | |
| 57 | - <SelectTrigger className="bg-white border border-gray-300 rounded-md w-[150px] shrink-0" style={{ height: 40, boxSizing: 'border-box' }}> | |
| 58 | - <SelectValue placeholder="Location" /> | |
| 59 | - </SelectTrigger> | |
| 60 | - <SelectContent> | |
| 61 | - <SelectItem value="all">All Locations</SelectItem> | |
| 62 | - <SelectItem value="loc-a">Location A</SelectItem> | |
| 63 | - <SelectItem value="loc-b">Location B</SelectItem> | |
| 64 | - </SelectContent> | |
| 65 | - </Select> | |
| 66 | - <span className="text-sm font-medium text-gray-900 whitespace-nowrap shrink-0">Location</span> | |
| 67 | - <Button className="bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md h-10 px-6 shrink-0 ml-auto"> | |
| 68 | - New Label Category <Plus className="ml-1 h-4 w-4" /> | |
| 69 | - </Button> | |
| 148 | + <div className="h-full flex flex-col"> | |
| 149 | + <div className="pb-4"> | |
| 150 | + <div className="flex flex-col gap-4"> | |
| 151 | + <div className="flex flex-nowrap items-center gap-3"> | |
| 152 | + <Input | |
| 153 | + placeholder="Search" | |
| 154 | + value={keyword} | |
| 155 | + onChange={(e) => setKeyword(e.target.value)} | |
| 156 | + style={{ height: 40, boxSizing: 'border-box' }} | |
| 157 | + className="bg-white border border-gray-300 rounded-md w-40 shrink-0 placeholder:text-gray-500" | |
| 158 | + /> | |
| 159 | + <Select value={stateFilter} onValueChange={setStateFilter}> | |
| 160 | + <SelectTrigger className="bg-white border border-gray-300 rounded-md w-[150px] shrink-0" style={{ height: 40, boxSizing: 'border-box' }}> | |
| 161 | + <SelectValue placeholder="State" /> | |
| 162 | + </SelectTrigger> | |
| 163 | + <SelectContent> | |
| 164 | + <SelectItem value="all">All States</SelectItem> | |
| 165 | + <SelectItem value="true">Active</SelectItem> | |
| 166 | + <SelectItem value="false">Inactive</SelectItem> | |
| 167 | + </SelectContent> | |
| 168 | + </Select> | |
| 169 | + <div className="flex-1" /> | |
| 170 | + <Button | |
| 171 | + className="bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md h-10 px-6 shrink-0" | |
| 172 | + onClick={() => setIsCreateDialogOpen(true)} | |
| 173 | + > | |
| 174 | + New Label Category <Plus className="ml-1 h-4 w-4" /> | |
| 175 | + </Button> | |
| 176 | + </div> | |
| 177 | + </div> | |
| 70 | 178 | </div> |
| 71 | 179 | |
| 72 | - {/* Table */} | |
| 73 | - <div className="rounded-md border bg-white shadow-sm"> | |
| 74 | - <Table> | |
| 75 | - <TableHeader> | |
| 76 | - <TableRow className="bg-gray-50 hover:bg-gray-50"> | |
| 77 | - <TableHead className="font-bold text-gray-900 w-[250px]">Label Category</TableHead> | |
| 78 | - <TableHead className="font-bold text-gray-900 w-[200px]">No. of Labels</TableHead> | |
| 79 | - <TableHead className="font-bold text-gray-900 w-[200px]">Category Photo</TableHead> | |
| 80 | - <TableHead className="font-bold text-gray-900">Last Edited</TableHead> | |
| 81 | - </TableRow> | |
| 82 | - </TableHeader> | |
| 83 | - <TableBody> | |
| 84 | - {categories.map((item) => ( | |
| 85 | - <TableRow key={item.id} className="hover:bg-gray-50"> | |
| 86 | - <TableCell className="font-medium">{item.category}</TableCell> | |
| 87 | - <TableCell className="font-numeric">{item.count}</TableCell> | |
| 88 | - <TableCell className="text-gray-500">{item.photo}</TableCell> | |
| 89 | - <TableCell className="text-gray-500 tabular-nums font-numeric">{item.lastEdited}</TableCell> | |
| 180 | + <div className="flex-1 overflow-auto pt-6"> | |
| 181 | + <div className="rounded-md border bg-white shadow-sm"> | |
| 182 | + <Table> | |
| 183 | + <TableHeader> | |
| 184 | + <TableRow className="bg-gray-50 hover:bg-gray-50"> | |
| 185 | + <TableHead className="font-bold text-gray-900 w-[250px]">Label Category</TableHead> | |
| 186 | + <TableHead className="font-bold text-gray-900 w-[200px]">Category Code</TableHead> | |
| 187 | + <TableHead className="font-bold text-gray-900 w-[200px]">Category Photo</TableHead> | |
| 188 | + <TableHead className="font-bold text-gray-900 w-[100px]">State</TableHead> | |
| 189 | + <TableHead className="font-bold text-gray-900 w-[100px]">Order</TableHead> | |
| 190 | + <TableHead className="font-bold text-gray-900">Last Edited</TableHead> | |
| 191 | + <TableHead className="font-bold text-gray-900 text-center w-[100px]">Actions</TableHead> | |
| 90 | 192 | </TableRow> |
| 91 | - ))} | |
| 92 | - </TableBody> | |
| 93 | - </Table> | |
| 193 | + </TableHeader> | |
| 194 | + <TableBody> | |
| 195 | + {loading ? ( | |
| 196 | + <TableRow> | |
| 197 | + <TableCell colSpan={7} className="text-center text-sm text-gray-500 py-10"> | |
| 198 | + Loading... | |
| 199 | + </TableCell> | |
| 200 | + </TableRow> | |
| 201 | + ) : categories.length === 0 ? ( | |
| 202 | + <TableRow> | |
| 203 | + <TableCell colSpan={7} className="text-center text-sm text-gray-500 py-10"> | |
| 204 | + No results. | |
| 205 | + </TableCell> | |
| 206 | + </TableRow> | |
| 207 | + ) : ( | |
| 208 | + categories.map((item) => ( | |
| 209 | + <TableRow key={item.id} className="hover:bg-gray-50"> | |
| 210 | + <TableCell className="font-medium">{toDisplay(item.categoryName)}</TableCell> | |
| 211 | + <TableCell className="text-gray-600">{toDisplay(item.categoryCode)}</TableCell> | |
| 212 | + <TableCell className="text-gray-500">{toDisplay(item.categoryPhotoUrl)}</TableCell> | |
| 213 | + <TableCell> | |
| 214 | + <Badge className={item.state ? "bg-green-600" : "bg-gray-400"}> | |
| 215 | + {item.state ? "Active" : "Inactive"} | |
| 216 | + </Badge> | |
| 217 | + </TableCell> | |
| 218 | + <TableCell className="font-numeric">{item.orderNum ?? "None"}</TableCell> | |
| 219 | + <TableCell className="text-gray-500 tabular-nums font-numeric"> | |
| 220 | + {item.creationTime ? new Date(item.creationTime).toLocaleString() : "None"} | |
| 221 | + </TableCell> | |
| 222 | + <TableCell className="text-center"> | |
| 223 | + <Popover | |
| 224 | + open={actionsOpenForId === item.id} | |
| 225 | + onOpenChange={(open) => setActionsOpenForId(open ? item.id : null)} | |
| 226 | + > | |
| 227 | + <PopoverTrigger asChild> | |
| 228 | + <Button | |
| 229 | + type="button" | |
| 230 | + variant="ghost" | |
| 231 | + size="icon" | |
| 232 | + className="h-8 w-8" | |
| 233 | + aria-label="Row actions" | |
| 234 | + > | |
| 235 | + <MoreHorizontal className="h-4 w-4 text-gray-500" /> | |
| 236 | + </Button> | |
| 237 | + </PopoverTrigger> | |
| 238 | + <PopoverContent align="end" className="w-40 p-1"> | |
| 239 | + <Button | |
| 240 | + type="button" | |
| 241 | + variant="ghost" | |
| 242 | + className="w-full justify-start gap-2 h-9 px-2 font-normal" | |
| 243 | + onClick={() => openEdit(item)} | |
| 244 | + > | |
| 245 | + <Edit className="w-4 h-4" /> | |
| 246 | + Edit | |
| 247 | + </Button> | |
| 248 | + <Button | |
| 249 | + type="button" | |
| 250 | + variant="ghost" | |
| 251 | + className="w-full justify-start h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50" | |
| 252 | + onClick={() => openDelete(item)} | |
| 253 | + > | |
| 254 | + Delete | |
| 255 | + </Button> | |
| 256 | + </PopoverContent> | |
| 257 | + </Popover> | |
| 258 | + </TableCell> | |
| 259 | + </TableRow> | |
| 260 | + )) | |
| 261 | + )} | |
| 262 | + </TableBody> | |
| 263 | + </Table> | |
| 264 | + </div> | |
| 94 | 265 | </div> |
| 266 | + | |
| 267 | + <div className="pt-4"> | |
| 268 | + <div className="flex items-center justify-between text-sm text-gray-600"> | |
| 269 | + <div> | |
| 270 | + Showing {total === 0 ? 0 : (pageIndex - 1) * pageSize + 1}- | |
| 271 | + {Math.min(pageIndex * pageSize, total)} of {total} | |
| 272 | + </div> | |
| 273 | + <div className="flex items-center gap-3"> | |
| 274 | + <Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}> | |
| 275 | + <SelectTrigger className="w-[110px] h-9 rounded-md border border-gray-300 bg-white text-gray-900"> | |
| 276 | + <SelectValue /> | |
| 277 | + </SelectTrigger> | |
| 278 | + <SelectContent> | |
| 279 | + {[10, 20, 50].map((n) => ( | |
| 280 | + <SelectItem key={n} value={String(n)}> | |
| 281 | + {n} / page | |
| 282 | + </SelectItem> | |
| 283 | + ))} | |
| 284 | + </SelectContent> | |
| 285 | + </Select> | |
| 286 | + <Pagination className="mx-0 w-auto justify-end"> | |
| 287 | + <PaginationContent> | |
| 288 | + <PaginationItem> | |
| 289 | + <PaginationPrevious | |
| 290 | + href="#" | |
| 291 | + size="default" | |
| 292 | + onClick={(e) => { | |
| 293 | + e.preventDefault(); | |
| 294 | + setPageIndex((p) => Math.max(1, p - 1)); | |
| 295 | + }} | |
| 296 | + aria-disabled={pageIndex <= 1} | |
| 297 | + className={pageIndex <= 1 ? "pointer-events-none opacity-50" : ""} | |
| 298 | + /> | |
| 299 | + </PaginationItem> | |
| 300 | + <PaginationItem> | |
| 301 | + <PaginationLink | |
| 302 | + href="#" | |
| 303 | + isActive | |
| 304 | + size="default" | |
| 305 | + onClick={(e) => e.preventDefault()} | |
| 306 | + > | |
| 307 | + Page {pageIndex} / {totalPages} | |
| 308 | + </PaginationLink> | |
| 309 | + </PaginationItem> | |
| 310 | + <PaginationItem> | |
| 311 | + <PaginationNext | |
| 312 | + href="#" | |
| 313 | + size="default" | |
| 314 | + onClick={(e) => { | |
| 315 | + e.preventDefault(); | |
| 316 | + setPageIndex((p) => Math.min(totalPages, p + 1)); | |
| 317 | + }} | |
| 318 | + aria-disabled={pageIndex >= totalPages} | |
| 319 | + className={pageIndex >= totalPages ? "pointer-events-none opacity-50" : ""} | |
| 320 | + /> | |
| 321 | + </PaginationItem> | |
| 322 | + </PaginationContent> | |
| 323 | + </Pagination> | |
| 324 | + </div> | |
| 325 | + </div> | |
| 326 | + </div> | |
| 327 | + | |
| 328 | + <CreateLabelCategoryDialog | |
| 329 | + open={isCreateDialogOpen} | |
| 330 | + onOpenChange={setIsCreateDialogOpen} | |
| 331 | + onCreated={() => { | |
| 332 | + setPageIndex(1); | |
| 333 | + refreshList(); | |
| 334 | + }} | |
| 335 | + /> | |
| 336 | + | |
| 337 | + <EditLabelCategoryDialog | |
| 338 | + open={isEditDialogOpen} | |
| 339 | + category={editingCategory} | |
| 340 | + onOpenChange={(open) => { | |
| 341 | + setIsEditDialogOpen(open); | |
| 342 | + if (!open) setEditingCategory(null); | |
| 343 | + }} | |
| 344 | + onUpdated={refreshList} | |
| 345 | + /> | |
| 346 | + | |
| 347 | + <DeleteLabelCategoryDialog | |
| 348 | + open={isDeleteDialogOpen} | |
| 349 | + category={deletingCategory} | |
| 350 | + onOpenChange={(open) => { | |
| 351 | + setIsDeleteDialogOpen(open); | |
| 352 | + if (!open) setDeletingCategory(null); | |
| 353 | + }} | |
| 354 | + onDeleted={refreshList} | |
| 355 | + /> | |
| 95 | 356 | </div> |
| 96 | 357 | ); |
| 97 | 358 | } |
| 359 | + | |
| 360 | +function CreateLabelCategoryDialog({ | |
| 361 | + open, | |
| 362 | + onOpenChange, | |
| 363 | + onCreated, | |
| 364 | +}: { | |
| 365 | + open: boolean; | |
| 366 | + onOpenChange: (open: boolean) => void; | |
| 367 | + onCreated: () => void; | |
| 368 | +}) { | |
| 369 | + const [submitting, setSubmitting] = useState(false); | |
| 370 | + const [form, setForm] = useState<LabelCategoryCreateInput>({ | |
| 371 | + categoryCode: "", | |
| 372 | + categoryName: "", | |
| 373 | + categoryPhotoUrl: null, | |
| 374 | + state: true, | |
| 375 | + orderNum: null, | |
| 376 | + }); | |
| 377 | + | |
| 378 | + const resetForm = () => { | |
| 379 | + setForm({ | |
| 380 | + categoryCode: "", | |
| 381 | + categoryName: "", | |
| 382 | + categoryPhotoUrl: null, | |
| 383 | + state: true, | |
| 384 | + orderNum: null, | |
| 385 | + }); | |
| 386 | + }; | |
| 387 | + | |
| 388 | + useEffect(() => { | |
| 389 | + if (!open) { | |
| 390 | + resetForm(); | |
| 391 | + } | |
| 392 | + }, [open]); | |
| 393 | + | |
| 394 | + const submit = async () => { | |
| 395 | + if (!form.categoryCode.trim() || !form.categoryName.trim()) { | |
| 396 | + toast.error("Validation failed", { | |
| 397 | + description: "Category Code and Category Name are required.", | |
| 398 | + }); | |
| 399 | + return; | |
| 400 | + } | |
| 401 | + | |
| 402 | + setSubmitting(true); | |
| 403 | + try { | |
| 404 | + await createLabelCategory(form); | |
| 405 | + toast.success("Label category created.", { | |
| 406 | + description: "The label category has been created successfully.", | |
| 407 | + }); | |
| 408 | + onOpenChange(false); | |
| 409 | + onCreated(); | |
| 410 | + } catch (e: any) { | |
| 411 | + toast.error("Failed to create label category.", { | |
| 412 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 413 | + }); | |
| 414 | + } finally { | |
| 415 | + setSubmitting(false); | |
| 416 | + } | |
| 417 | + }; | |
| 418 | + | |
| 419 | + return ( | |
| 420 | + <Dialog open={open} onOpenChange={onOpenChange}> | |
| 421 | + <DialogContent className="sm:max-w-[600px]"> | |
| 422 | + <DialogHeader> | |
| 423 | + <DialogTitle>Add New Label Category</DialogTitle> | |
| 424 | + <DialogDescription> | |
| 425 | + Enter the details for the new label category. | |
| 426 | + </DialogDescription> | |
| 427 | + </DialogHeader> | |
| 428 | + | |
| 429 | + <div className="grid gap-4 py-4"> | |
| 430 | + <div className="grid grid-cols-2 gap-4"> | |
| 431 | + <div className="space-y-2"> | |
| 432 | + <Label>Category Code *</Label> | |
| 433 | + <Input | |
| 434 | + placeholder="e.g. CAT_PREP" | |
| 435 | + value={form.categoryCode} | |
| 436 | + onChange={(e) => setForm((p) => ({ ...p, categoryCode: e.target.value }))} | |
| 437 | + /> | |
| 438 | + </div> | |
| 439 | + <div className="space-y-2"> | |
| 440 | + <Label>Category Name *</Label> | |
| 441 | + <Input | |
| 442 | + placeholder="e.g. Prep" | |
| 443 | + value={form.categoryName} | |
| 444 | + onChange={(e) => setForm((p) => ({ ...p, categoryName: e.target.value }))} | |
| 445 | + /> | |
| 446 | + </div> | |
| 447 | + </div> | |
| 448 | + | |
| 449 | + <div className="space-y-2"> | |
| 450 | + <Label>Category Photo URL</Label> | |
| 451 | + <Input | |
| 452 | + placeholder="https://cdn.example.com/cat-prep.png" | |
| 453 | + value={form.categoryPhotoUrl ?? ""} | |
| 454 | + onChange={(e) => setForm((p) => ({ ...p, categoryPhotoUrl: e.target.value || null }))} | |
| 455 | + /> | |
| 456 | + </div> | |
| 457 | + | |
| 458 | + <div className="grid grid-cols-2 gap-4"> | |
| 459 | + <div className="space-y-2"> | |
| 460 | + <Label>Order</Label> | |
| 461 | + <Input | |
| 462 | + type="number" | |
| 463 | + placeholder="e.g. 1" | |
| 464 | + value={form.orderNum ?? ""} | |
| 465 | + onChange={(e) => setForm((p) => ({ ...p, orderNum: e.target.value ? Number(e.target.value) : null }))} | |
| 466 | + /> | |
| 467 | + </div> | |
| 468 | + <div className="flex items-center justify-between border border-gray-200 rounded-md px-3 bg-white" style={{ height: 40 }}> | |
| 469 | + <div className="text-sm font-medium text-gray-900">Enabled</div> | |
| 470 | + <Switch checked={form.state} onCheckedChange={(checked) => setForm((p) => ({ ...p, state: checked }))} /> | |
| 471 | + </div> | |
| 472 | + </div> | |
| 473 | + </div> | |
| 474 | + | |
| 475 | + <DialogFooter> | |
| 476 | + <Button variant="outline" onClick={() => onOpenChange(false)}> | |
| 477 | + Cancel | |
| 478 | + </Button> | |
| 479 | + <Button disabled={submitting} onClick={submit}> | |
| 480 | + {submitting ? "Creating..." : "Create"} | |
| 481 | + </Button> | |
| 482 | + </DialogFooter> | |
| 483 | + </DialogContent> | |
| 484 | + </Dialog> | |
| 485 | + ); | |
| 486 | +} | |
| 487 | + | |
| 488 | +function EditLabelCategoryDialog({ | |
| 489 | + open, | |
| 490 | + category, | |
| 491 | + onOpenChange, | |
| 492 | + onUpdated, | |
| 493 | +}: { | |
| 494 | + open: boolean; | |
| 495 | + category: LabelCategoryDto | null; | |
| 496 | + onOpenChange: (open: boolean) => void; | |
| 497 | + onUpdated: () => void; | |
| 498 | +}) { | |
| 499 | + const [submitting, setSubmitting] = useState(false); | |
| 500 | + const [loading, setLoading] = useState(false); | |
| 501 | + const [form, setForm] = useState<LabelCategoryUpdateInput>({ | |
| 502 | + categoryCode: "", | |
| 503 | + categoryName: "", | |
| 504 | + categoryPhotoUrl: null, | |
| 505 | + state: true, | |
| 506 | + orderNum: null, | |
| 507 | + }); | |
| 508 | + | |
| 509 | + useEffect(() => { | |
| 510 | + if (open && category) { | |
| 511 | + setForm({ | |
| 512 | + categoryCode: category.categoryCode ?? "", | |
| 513 | + categoryName: category.categoryName ?? "", | |
| 514 | + categoryPhotoUrl: category.categoryPhotoUrl ?? null, | |
| 515 | + state: category.state ?? true, | |
| 516 | + orderNum: category.orderNum ?? null, | |
| 517 | + }); | |
| 518 | + } | |
| 519 | + }, [open, category]); | |
| 520 | + | |
| 521 | + const submit = async () => { | |
| 522 | + if (!category?.id) return; | |
| 523 | + if (!form.categoryCode.trim() || !form.categoryName.trim()) { | |
| 524 | + toast.error("Validation failed", { | |
| 525 | + description: "Category Code and Category Name are required.", | |
| 526 | + }); | |
| 527 | + return; | |
| 528 | + } | |
| 529 | + | |
| 530 | + setSubmitting(true); | |
| 531 | + try { | |
| 532 | + await updateLabelCategory(category.id, form); | |
| 533 | + toast.success("Label category updated.", { | |
| 534 | + description: "The label category has been updated successfully.", | |
| 535 | + }); | |
| 536 | + onOpenChange(false); | |
| 537 | + onUpdated(); | |
| 538 | + } catch (e: any) { | |
| 539 | + toast.error("Failed to update label category.", { | |
| 540 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 541 | + }); | |
| 542 | + } finally { | |
| 543 | + setSubmitting(false); | |
| 544 | + } | |
| 545 | + }; | |
| 546 | + | |
| 547 | + return ( | |
| 548 | + <Dialog open={open} onOpenChange={onOpenChange}> | |
| 549 | + <DialogContent className="sm:max-w-[600px]"> | |
| 550 | + <DialogHeader> | |
| 551 | + <DialogTitle>Edit Label Category</DialogTitle> | |
| 552 | + <DialogDescription> | |
| 553 | + Update the label category details. | |
| 554 | + </DialogDescription> | |
| 555 | + </DialogHeader> | |
| 556 | + | |
| 557 | + <div className="grid gap-4 py-4"> | |
| 558 | + <div className="grid grid-cols-2 gap-4"> | |
| 559 | + <div className="space-y-2"> | |
| 560 | + <Label>Category Code *</Label> | |
| 561 | + <Input | |
| 562 | + placeholder="e.g. CAT_PREP" | |
| 563 | + value={form.categoryCode} | |
| 564 | + onChange={(e) => setForm((p) => ({ ...p, categoryCode: e.target.value }))} | |
| 565 | + /> | |
| 566 | + </div> | |
| 567 | + <div className="space-y-2"> | |
| 568 | + <Label>Category Name *</Label> | |
| 569 | + <Input | |
| 570 | + placeholder="e.g. Prep" | |
| 571 | + value={form.categoryName} | |
| 572 | + onChange={(e) => setForm((p) => ({ ...p, categoryName: e.target.value }))} | |
| 573 | + /> | |
| 574 | + </div> | |
| 575 | + </div> | |
| 576 | + | |
| 577 | + <div className="space-y-2"> | |
| 578 | + <Label>Category Photo URL</Label> | |
| 579 | + <Input | |
| 580 | + placeholder="https://cdn.example.com/cat-prep.png" | |
| 581 | + value={form.categoryPhotoUrl ?? ""} | |
| 582 | + onChange={(e) => setForm((p) => ({ ...p, categoryPhotoUrl: e.target.value || null }))} | |
| 583 | + /> | |
| 584 | + </div> | |
| 585 | + | |
| 586 | + <div className="grid grid-cols-2 gap-4"> | |
| 587 | + <div className="space-y-2"> | |
| 588 | + <Label>Order</Label> | |
| 589 | + <Input | |
| 590 | + type="number" | |
| 591 | + placeholder="e.g. 1" | |
| 592 | + value={form.orderNum ?? ""} | |
| 593 | + onChange={(e) => setForm((p) => ({ ...p, orderNum: e.target.value ? Number(e.target.value) : null }))} | |
| 594 | + /> | |
| 595 | + </div> | |
| 596 | + <div className="flex items-center justify-between border border-gray-200 rounded-md px-3 bg-white" style={{ height: 40 }}> | |
| 597 | + <div className="text-sm font-medium text-gray-900">Enabled</div> | |
| 598 | + <Switch checked={form.state} onCheckedChange={(checked) => setForm((p) => ({ ...p, state: checked }))} /> | |
| 599 | + </div> | |
| 600 | + </div> | |
| 601 | + </div> | |
| 602 | + | |
| 603 | + <DialogFooter> | |
| 604 | + <Button variant="outline" onClick={() => onOpenChange(false)}> | |
| 605 | + Cancel | |
| 606 | + </Button> | |
| 607 | + <Button disabled={submitting} onClick={submit}> | |
| 608 | + {submitting ? "Updating..." : "Update"} | |
| 609 | + </Button> | |
| 610 | + </DialogFooter> | |
| 611 | + </DialogContent> | |
| 612 | + </Dialog> | |
| 613 | + ); | |
| 614 | +} | |
| 615 | + | |
| 616 | +function DeleteLabelCategoryDialog({ | |
| 617 | + open, | |
| 618 | + category, | |
| 619 | + onOpenChange, | |
| 620 | + onDeleted, | |
| 621 | +}: { | |
| 622 | + open: boolean; | |
| 623 | + category: LabelCategoryDto | null; | |
| 624 | + onOpenChange: (open: boolean) => void; | |
| 625 | + onDeleted: () => void; | |
| 626 | +}) { | |
| 627 | + const [submitting, setSubmitting] = useState(false); | |
| 628 | + | |
| 629 | + const name = useMemo(() => { | |
| 630 | + const n = (category?.categoryName ?? "").trim(); | |
| 631 | + return n || category?.categoryCode || "this category"; | |
| 632 | + }, [category]); | |
| 633 | + | |
| 634 | + const submit = async () => { | |
| 635 | + if (!category?.id) return; | |
| 636 | + setSubmitting(true); | |
| 637 | + try { | |
| 638 | + await deleteLabelCategory(category.id); | |
| 639 | + toast.success("Label category deleted.", { | |
| 640 | + description: "The label category has been removed successfully.", | |
| 641 | + }); | |
| 642 | + onOpenChange(false); | |
| 643 | + onDeleted(); | |
| 644 | + } catch (e: any) { | |
| 645 | + toast.error("Failed to delete label category.", { | |
| 646 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 647 | + }); | |
| 648 | + } finally { | |
| 649 | + setSubmitting(false); | |
| 650 | + } | |
| 651 | + }; | |
| 652 | + | |
| 653 | + return ( | |
| 654 | + <Dialog open={open} onOpenChange={onOpenChange}> | |
| 655 | + <DialogContent className="sm:max-w-none" style={{ width: "30%" }}> | |
| 656 | + <DialogHeader> | |
| 657 | + <DialogTitle>Delete Label Category</DialogTitle> | |
| 658 | + <DialogDescription>This action cannot be undone.</DialogDescription> | |
| 659 | + </DialogHeader> | |
| 660 | + | |
| 661 | + <div className="text-sm text-gray-700"> | |
| 662 | + Are you sure you want to delete <span className="font-medium">{name}</span>? | |
| 663 | + </div> | |
| 664 | + | |
| 665 | + <DialogFooter className="flex-row flex-wrap justify-end"> | |
| 666 | + <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}> | |
| 667 | + Cancel | |
| 668 | + </Button> | |
| 669 | + <Button | |
| 670 | + className="min-w-24" | |
| 671 | + variant="destructive" | |
| 672 | + disabled={submitting} | |
| 673 | + onClick={submit} | |
| 674 | + > | |
| 675 | + {submitting ? "Deleting..." : "Delete"} | |
| 676 | + </Button> | |
| 677 | + </DialogFooter> | |
| 678 | + </DialogContent> | |
| 679 | + </Dialog> | |
| 680 | + ); | |
| 681 | +} | ... | ... |
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/LabelCanvas.tsx
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/PropertiesPanel.tsx
| ... | ... | @@ -18,7 +18,10 @@ import type { |
| 18 | 18 | Unit, |
| 19 | 19 | Rotation, |
| 20 | 20 | Border, |
| 21 | + AppliedLocation, | |
| 21 | 22 | } from '../../../types/labelTemplate'; |
| 23 | +import type { LocationDto } from '../../../types/location'; | |
| 24 | +import { Checkbox } from '../../ui/checkbox'; | |
| 22 | 25 | |
| 23 | 26 | interface PropertiesPanelProps { |
| 24 | 27 | template: LabelTemplate; |
| ... | ... | @@ -26,6 +29,10 @@ interface PropertiesPanelProps { |
| 26 | 29 | onTemplateChange: (patch: Partial<LabelTemplate>) => void; |
| 27 | 30 | onElementChange: (id: string, patch: Partial<LabelElement>) => void; |
| 28 | 31 | onDeleteElement?: (id: string) => void; |
| 32 | + /** 门店列表:appliedLocation=SPECIFIED 时勾选 */ | |
| 33 | + locations?: LocationDto[]; | |
| 34 | + /** 编辑已有模板时禁止修改 Template Code */ | |
| 35 | + readOnlyTemplateCode?: boolean; | |
| 29 | 36 | } |
| 30 | 37 | |
| 31 | 38 | export function PropertiesPanel({ |
| ... | ... | @@ -34,6 +41,8 @@ export function PropertiesPanel({ |
| 34 | 41 | onTemplateChange, |
| 35 | 42 | onElementChange, |
| 36 | 43 | onDeleteElement, |
| 44 | + locations = [], | |
| 45 | + readOnlyTemplateCode = false, | |
| 37 | 46 | }: PropertiesPanelProps) { |
| 38 | 47 | if (selectedElement) { |
| 39 | 48 | return ( |
| ... | ... | @@ -165,6 +174,16 @@ export function PropertiesPanel({ |
| 165 | 174 | <ScrollArea className="flex-1"> |
| 166 | 175 | <div className="p-3 space-y-3"> |
| 167 | 176 | <div> |
| 177 | + <Label className="text-xs">Template Code</Label> | |
| 178 | + <Input | |
| 179 | + value={template.id} | |
| 180 | + disabled={readOnlyTemplateCode} | |
| 181 | + onChange={(e) => onTemplateChange({ id: e.target.value.trim() })} | |
| 182 | + className="h-8 text-sm mt-1" | |
| 183 | + placeholder="e.g. TPL_TEST_001" | |
| 184 | + /> | |
| 185 | + </div> | |
| 186 | + <div> | |
| 168 | 187 | <Label className="text-xs">Template Name</Label> |
| 169 | 188 | <Input |
| 170 | 189 | value={template.name} |
| ... | ... | @@ -192,18 +211,55 @@ export function PropertiesPanel({ |
| 192 | 211 | <Label className="text-xs">Applied Location</Label> |
| 193 | 212 | <Select |
| 194 | 213 | value={template.appliedLocation} |
| 195 | - onValueChange={(v) => onTemplateChange({ appliedLocation: v })} | |
| 214 | + onValueChange={(v: AppliedLocation) => { | |
| 215 | + if (v === "ALL") { | |
| 216 | + onTemplateChange({ appliedLocation: v, appliedLocationIds: [] }); | |
| 217 | + } else { | |
| 218 | + onTemplateChange({ appliedLocation: v }); | |
| 219 | + } | |
| 220 | + }} | |
| 196 | 221 | > |
| 197 | 222 | <SelectTrigger className="h-8 text-sm mt-1"> |
| 198 | 223 | <SelectValue /> |
| 199 | 224 | </SelectTrigger> |
| 200 | 225 | <SelectContent> |
| 201 | - <SelectItem value="ALL">All Locations</SelectItem> | |
| 202 | - <SelectItem value="loc-a">Location A</SelectItem> | |
| 203 | - <SelectItem value="loc-b">Location B</SelectItem> | |
| 226 | + <SelectItem value="ALL">All locations</SelectItem> | |
| 227 | + <SelectItem value="SPECIFIED">Specified locations</SelectItem> | |
| 204 | 228 | </SelectContent> |
| 205 | 229 | </Select> |
| 206 | 230 | </div> |
| 231 | + {template.appliedLocation === "SPECIFIED" && ( | |
| 232 | + <div className="rounded-md border border-gray-200 p-2 max-h-40 overflow-y-auto space-y-2"> | |
| 233 | + <Label className="text-xs text-gray-600">Select locations</Label> | |
| 234 | + {locations.length === 0 ? ( | |
| 235 | + <p className="text-xs text-gray-500">No locations loaded.</p> | |
| 236 | + ) : ( | |
| 237 | + locations.map((loc) => { | |
| 238 | + const checked = (template.appliedLocationIds ?? []).includes(loc.id); | |
| 239 | + return ( | |
| 240 | + <label | |
| 241 | + key={loc.id} | |
| 242 | + className="flex items-center gap-2 text-xs cursor-pointer" | |
| 243 | + > | |
| 244 | + <Checkbox | |
| 245 | + checked={checked} | |
| 246 | + onCheckedChange={(v) => { | |
| 247 | + const on = v === true; | |
| 248 | + const cur = new Set(template.appliedLocationIds ?? []); | |
| 249 | + if (on) cur.add(loc.id); | |
| 250 | + else cur.delete(loc.id); | |
| 251 | + onTemplateChange({ appliedLocationIds: Array.from(cur) }); | |
| 252 | + }} | |
| 253 | + /> | |
| 254 | + <span className="truncate"> | |
| 255 | + {(loc.locationName ?? loc.locationCode ?? loc.id).trim() || loc.id} | |
| 256 | + </span> | |
| 257 | + </label> | |
| 258 | + ); | |
| 259 | + }) | |
| 260 | + )} | |
| 261 | + </div> | |
| 262 | + )} | |
| 207 | 263 | <div className="grid grid-cols-2 gap-2"> |
| 208 | 264 | <div> |
| 209 | 265 | <Label className="text-xs">Width</Label> |
| ... | ... | @@ -250,6 +306,13 @@ export function PropertiesPanel({ |
| 250 | 306 | /> |
| 251 | 307 | <Label className="text-xs">Show Ruler</Label> |
| 252 | 308 | </div> |
| 309 | + <div className="flex items-center gap-2"> | |
| 310 | + <Switch | |
| 311 | + checked={template.showGrid ?? true} | |
| 312 | + onCheckedChange={(v) => onTemplateChange({ showGrid: v })} | |
| 313 | + /> | |
| 314 | + <Label className="text-xs">Show Grid</Label> | |
| 315 | + </div> | |
| 253 | 316 | </div> |
| 254 | 317 | </ScrollArea> |
| 255 | 318 | </div> |
| ... | ... | @@ -554,7 +617,7 @@ function ElementConfigFields({ |
| 554 | 617 | case 'BLANK': |
| 555 | 618 | return ( |
| 556 | 619 | <div className="text-xs text-gray-500"> |
| 557 | - 空白占位元素,无需配置 | |
| 620 | + Blank spacer; no configuration needed. | |
| 558 | 621 | </div> |
| 559 | 622 | ); |
| 560 | 623 | default: | ... | ... |
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/index.tsx
| 1 | -import React, { useCallback, useState } from 'react'; | |
| 1 | +import React, { useCallback, useEffect, useState } from 'react'; | |
| 2 | 2 | import { Button } from '../../ui/button'; |
| 3 | 3 | import { ArrowLeft, Save, Download } from 'lucide-react'; |
| 4 | 4 | import { |
| ... | ... | @@ -8,11 +8,18 @@ import { |
| 8 | 8 | DialogTitle, |
| 9 | 9 | } from '../../ui/dialog'; |
| 10 | 10 | import type { LabelTemplate, LabelElement } from '../../../types/labelTemplate'; |
| 11 | -import { createDefaultTemplate, createDefaultElement } from '../../../types/labelTemplate'; | |
| 11 | +import { | |
| 12 | + createDefaultTemplate, | |
| 13 | + createDefaultElement, | |
| 14 | + defaultValueSourceTypeForElement, | |
| 15 | +} from '../../../types/labelTemplate'; | |
| 16 | +import type { LocationDto } from '../../../types/location'; | |
| 17 | +import { getLocations } from '../../../services/locationService'; | |
| 12 | 18 | import { ElementsPanel } from './ElementsPanel'; |
| 13 | 19 | import { LabelCanvas, LabelPreviewOnly } from './LabelCanvas'; |
| 14 | 20 | import { PropertiesPanel } from './PropertiesPanel'; |
| 15 | -import { saveTemplate } from '../../../lib/labelTemplateStorage'; | |
| 21 | +import { createLabelTemplate, updateLabelTemplate } from '../../../services/labelTemplateService'; | |
| 22 | +import { toast } from 'sonner'; | |
| 16 | 23 | |
| 17 | 24 | const MIN_SCALE = 0.5; |
| 18 | 25 | const MAX_SCALE = 2; |
| ... | ... | @@ -40,6 +47,22 @@ export function LabelTemplateEditor({ |
| 40 | 47 | const [selectedId, setSelectedId] = useState<string | null>(null); |
| 41 | 48 | const [scale, setScale] = useState(DEFAULT_SCALE); |
| 42 | 49 | const [previewOpen, setPreviewOpen] = useState(false); |
| 50 | + const [locations, setLocations] = useState<LocationDto[]>([]); | |
| 51 | + | |
| 52 | + useEffect(() => { | |
| 53 | + let cancelled = false; | |
| 54 | + (async () => { | |
| 55 | + try { | |
| 56 | + const res = await getLocations({ skipCount: 0, maxResultCount: 500 }); | |
| 57 | + if (!cancelled) setLocations(res.items ?? []); | |
| 58 | + } catch { | |
| 59 | + if (!cancelled) setLocations([]); | |
| 60 | + } | |
| 61 | + })(); | |
| 62 | + return () => { | |
| 63 | + cancelled = true; | |
| 64 | + }; | |
| 65 | + }, []); | |
| 43 | 66 | |
| 44 | 67 | const selectedElement = template.elements.find((el) => el.id === selectedId) ?? null; |
| 45 | 68 | |
| ... | ... | @@ -141,11 +164,74 @@ export function LabelTemplateEditor({ |
| 141 | 164 | setTemplate((prev) => ({ ...prev, ...patch })); |
| 142 | 165 | }, []); |
| 143 | 166 | |
| 144 | - const handleSave = useCallback(() => { | |
| 145 | - saveTemplate(template); | |
| 146 | - onSaved(); | |
| 147 | - onClose(); | |
| 148 | - }, [template, onSaved, onClose]); | |
| 167 | + const handleSave = useCallback(async () => { | |
| 168 | + try { | |
| 169 | + const code = (template.id ?? "").trim(); | |
| 170 | + if (!code) { | |
| 171 | + toast.error("Template code is required.", { | |
| 172 | + description: "Please enter a template code (e.g. TPL_TEST_001).", | |
| 173 | + }); | |
| 174 | + return; | |
| 175 | + } | |
| 176 | + if (template.appliedLocation === "SPECIFIED" && !(template.appliedLocationIds?.length ?? 0)) { | |
| 177 | + toast.error("Locations required.", { | |
| 178 | + description: "When using specified locations, select at least one location.", | |
| 179 | + }); | |
| 180 | + return; | |
| 181 | + } | |
| 182 | + | |
| 183 | + // 转换 LabelTemplate 到 API 需要的格式(对齐 LabelTemplateCreateInputVo) | |
| 184 | + const apiInput = { | |
| 185 | + id: code, | |
| 186 | + name: template.name, | |
| 187 | + labelType: template.labelType, | |
| 188 | + unit: template.unit, | |
| 189 | + width: template.width, | |
| 190 | + height: template.height, | |
| 191 | + appliedLocation: template.appliedLocation, | |
| 192 | + showRuler: template.showRuler, | |
| 193 | + showGrid: template.showGrid ?? true, | |
| 194 | + state: true, | |
| 195 | + elements: template.elements.map((el, index) => ({ | |
| 196 | + id: el.id, | |
| 197 | + type: el.type, | |
| 198 | + x: el.x, | |
| 199 | + y: el.y, | |
| 200 | + width: el.width, | |
| 201 | + height: el.height, | |
| 202 | + rotation: el.rotation, | |
| 203 | + border: el.border, | |
| 204 | + zIndex: el.zIndex ?? index + 1, | |
| 205 | + orderNum: el.orderNum ?? index + 1, | |
| 206 | + valueSourceType: el.valueSourceType ?? defaultValueSourceTypeForElement(el.type), | |
| 207 | + isRequiredInput: el.isRequiredInput ?? false, | |
| 208 | + config: el.config, | |
| 209 | + })), | |
| 210 | + appliedLocationIds: | |
| 211 | + template.appliedLocation === "ALL" ? [] : (template.appliedLocationIds ?? []), | |
| 212 | + }; | |
| 213 | + | |
| 214 | + if (templateId) { | |
| 215 | + // 编辑模式:使用 TemplateCode 作为 id | |
| 216 | + await updateLabelTemplate(code, apiInput); | |
| 217 | + toast.success("Template updated.", { | |
| 218 | + description: "The template has been updated successfully.", | |
| 219 | + }); | |
| 220 | + } else { | |
| 221 | + // 新建模式 | |
| 222 | + await createLabelTemplate(apiInput); | |
| 223 | + toast.success("Template created.", { | |
| 224 | + description: "The template has been created successfully.", | |
| 225 | + }); | |
| 226 | + } | |
| 227 | + onSaved(); | |
| 228 | + onClose(); | |
| 229 | + } catch (e: any) { | |
| 230 | + toast.error("Failed to save template.", { | |
| 231 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 232 | + }); | |
| 233 | + } | |
| 234 | + }, [template, templateId, onSaved, onClose]); | |
| 149 | 235 | |
| 150 | 236 | const handleExport = useCallback(() => { |
| 151 | 237 | const blob = new Blob([JSON.stringify(template, null, 2)], { |
| ... | ... | @@ -213,6 +299,8 @@ export function LabelTemplateEditor({ |
| 213 | 299 | onTemplateChange={handleTemplateChange} |
| 214 | 300 | onElementChange={updateElement} |
| 215 | 301 | onDeleteElement={deleteElement} |
| 302 | + locations={locations} | |
| 303 | + readOnlyTemplateCode={!!templateId} | |
| 216 | 304 | /> |
| 217 | 305 | </div> |
| 218 | 306 | </div> | ... | ... |
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplatesView.tsx
| 1 | -import React, { useState, useCallback, useEffect } from 'react'; | |
| 1 | +import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; | |
| 2 | 2 | import { |
| 3 | 3 | Table, |
| 4 | 4 | TableBody, |
| ... | ... | @@ -16,52 +16,231 @@ import { |
| 16 | 16 | SelectTrigger, |
| 17 | 17 | SelectValue, |
| 18 | 18 | } from '../ui/select'; |
| 19 | -import { Plus, Pencil } from 'lucide-react'; | |
| 20 | -import { getTemplateList, getTemplate } from '../../lib/labelTemplateStorage'; | |
| 21 | -import type { LabelTemplate } from '../../types/labelTemplate'; | |
| 19 | +import { | |
| 20 | + Dialog, | |
| 21 | + DialogContent, | |
| 22 | + DialogDescription, | |
| 23 | + DialogFooter, | |
| 24 | + DialogHeader, | |
| 25 | + DialogTitle, | |
| 26 | +} from '../ui/dialog'; | |
| 27 | +import { Plus, Pencil, MoreHorizontal } from 'lucide-react'; | |
| 28 | +import { toast } from 'sonner'; | |
| 29 | +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; | |
| 30 | +import { | |
| 31 | + Pagination, | |
| 32 | + PaginationContent, | |
| 33 | + PaginationItem, | |
| 34 | + PaginationLink, | |
| 35 | + PaginationNext, | |
| 36 | + PaginationPrevious, | |
| 37 | +} from '../ui/pagination'; | |
| 38 | +import { getLabelTemplates, getLabelTemplate, deleteLabelTemplate } from '../../services/labelTemplateService'; | |
| 39 | +import { getLocations } from '../../services/locationService'; | |
| 40 | +import { appliedLocationToEditor, type LabelTemplateDto } from '../../types/labelTemplate'; | |
| 22 | 41 | import { LabelTemplateEditor } from './LabelTemplateEditor'; |
| 42 | +import type { LabelTemplate } from '../../types/labelTemplate'; | |
| 43 | +import type { LocationDto } from '../../types/location'; | |
| 44 | + | |
| 45 | +function toDisplay(v: string | null | undefined): string { | |
| 46 | + const s = (v ?? "").trim(); | |
| 47 | + return s ? s : "None"; | |
| 48 | +} | |
| 49 | + | |
| 50 | +function locationColumnText(t: LabelTemplateDto, locations: LocationDto[]): string { | |
| 51 | + const mode = appliedLocationToEditor(t); | |
| 52 | + if (mode === "ALL") return "All"; | |
| 53 | + const ids = t.appliedLocationIds ?? []; | |
| 54 | + if (ids.length === 0) return "Specified (0)"; | |
| 55 | + const names = ids.map( | |
| 56 | + (id) => locations.find((l) => l.id === id)?.locationName?.trim() || id, | |
| 57 | + ); | |
| 58 | + if (names.length <= 2) return names.join(", "); | |
| 59 | + return `${names.slice(0, 2).join(", ")} +${names.length - 2}`; | |
| 60 | +} | |
| 61 | + | |
| 62 | +/** 列表行:名称列 ← templateName / name */ | |
| 63 | +function templateListDisplayName(t: LabelTemplateDto): string { | |
| 64 | + const n = (t.templateName ?? t.name ?? "").trim(); | |
| 65 | + return n ? n : "None"; | |
| 66 | +} | |
| 67 | + | |
| 68 | +/** 列表行:模板编码列 ← templateCode / id */ | |
| 69 | +function templateListDisplayCode(t: LabelTemplateDto): string { | |
| 70 | + const c = (t.templateCode ?? t.id ?? "").trim(); | |
| 71 | + return c ? c : "None"; | |
| 72 | +} | |
| 73 | + | |
| 74 | +/** 列表行:门店展示 ← locationText,缺省时再推导 */ | |
| 75 | +function templateListDisplayLocation(t: LabelTemplateDto, locations: LocationDto[]): string { | |
| 76 | + const lt = (t.locationText ?? "").trim(); | |
| 77 | + if (lt) return lt; | |
| 78 | + return locationColumnText(t, locations); | |
| 79 | +} | |
| 80 | + | |
| 81 | +/** 列表行:元素数量 ← contentsCount / elements.length */ | |
| 82 | +function templateListContentsCount(t: LabelTemplateDto): number { | |
| 83 | + if (typeof t.contentsCount === "number") return t.contentsCount; | |
| 84 | + return t.elements?.length ?? 0; | |
| 85 | +} | |
| 86 | + | |
| 87 | +/** 列表行:尺寸 ← sizeText,缺省时用 width×height unit */ | |
| 88 | +function templateListDisplaySize(t: LabelTemplateDto): string { | |
| 89 | + const st = (t.sizeText ?? "").trim(); | |
| 90 | + if (st) return st; | |
| 91 | + const w = t.width; | |
| 92 | + const h = t.height; | |
| 93 | + const u = t.unit; | |
| 94 | + if (w != null && h != null && u) return `${w}×${h} ${u}`; | |
| 95 | + return "None"; | |
| 96 | +} | |
| 23 | 97 | |
| 24 | 98 | export function LabelTemplatesView() { |
| 25 | - const [templates, setTemplates] = useState<LabelTemplate[]>(() => getTemplateList()); | |
| 99 | + const [templates, setTemplates] = useState<LabelTemplateDto[]>([]); | |
| 26 | 100 | const [viewMode, setViewMode] = useState<'list' | 'editor'>('list'); |
| 27 | 101 | const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null); |
| 28 | - const [search, setSearch] = useState(''); | |
| 102 | + const [initialTemplate, setInitialTemplate] = useState<LabelTemplate | null>(null); | |
| 103 | + const [loading, setLoading] = useState(false); | |
| 104 | + const [total, setTotal] = useState(0); | |
| 105 | + const [refreshSeq, setRefreshSeq] = useState(0); | |
| 106 | + const [actionsOpenForId, setActionsOpenForId] = useState<string | null>(null); | |
| 107 | + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); | |
| 108 | + const [deletingTemplate, setDeletingTemplate] = useState<LabelTemplateDto | null>(null); | |
| 109 | + | |
| 110 | + const [keyword, setKeyword] = useState(''); | |
| 29 | 111 | const [locationFilter, setLocationFilter] = useState('all'); |
| 112 | + const [labelTypeFilter, setLabelTypeFilter] = useState<string>('all'); | |
| 113 | + const [stateFilter, setStateFilter] = useState<string>('all'); | |
| 114 | + | |
| 115 | + const [pageIndex, setPageIndex] = useState(1); | |
| 116 | + const [pageSize, setPageSize] = useState(10); | |
| 117 | + const [locations, setLocations] = useState<LocationDto[]>([]); | |
| 30 | 118 | |
| 31 | - const refreshList = useCallback(() => { | |
| 32 | - setTemplates(getTemplateList()); | |
| 119 | + const abortRef = useRef<AbortController | null>(null); | |
| 120 | + const keywordTimerRef = useRef<number | null>(null); | |
| 121 | + const [debouncedKeyword, setDebouncedKeyword] = useState(''); | |
| 122 | + | |
| 123 | + useEffect(() => { | |
| 124 | + if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); | |
| 125 | + keywordTimerRef.current = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300); | |
| 126 | + return () => { | |
| 127 | + if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); | |
| 128 | + }; | |
| 129 | + }, [keyword]); | |
| 130 | + | |
| 131 | + const totalPages = Math.max(1, Math.ceil(total / pageSize)); | |
| 132 | + | |
| 133 | + useEffect(() => { | |
| 134 | + let cancelled = false; | |
| 135 | + (async () => { | |
| 136 | + try { | |
| 137 | + const res = await getLocations({ skipCount: 0, maxResultCount: 500 }); | |
| 138 | + if (!cancelled) setLocations(res.items ?? []); | |
| 139 | + } catch { | |
| 140 | + if (!cancelled) setLocations([]); | |
| 141 | + } | |
| 142 | + })(); | |
| 143 | + return () => { | |
| 144 | + cancelled = true; | |
| 145 | + }; | |
| 33 | 146 | }, []); |
| 34 | 147 | |
| 35 | 148 | useEffect(() => { |
| 36 | - if (viewMode === 'list') refreshList(); | |
| 37 | - }, [viewMode, refreshList]); | |
| 149 | + setPageIndex(1); | |
| 150 | + }, [debouncedKeyword, locationFilter, labelTypeFilter, stateFilter, pageSize]); | |
| 38 | 151 | |
| 39 | - const filtered = templates.filter((t) => { | |
| 40 | - const matchSearch = | |
| 41 | - !search || t.name.toLowerCase().includes(search.toLowerCase()); | |
| 42 | - const matchLoc = | |
| 43 | - locationFilter === 'all' || t.appliedLocation === locationFilter; | |
| 44 | - return matchSearch && matchLoc; | |
| 45 | - }); | |
| 152 | + useEffect(() => { | |
| 153 | + if (viewMode !== 'list') return; | |
| 154 | + | |
| 155 | + const run = async () => { | |
| 156 | + abortRef.current?.abort(); | |
| 157 | + const ac = new AbortController(); | |
| 158 | + abortRef.current = ac; | |
| 159 | + | |
| 160 | + setLoading(true); | |
| 161 | + try { | |
| 162 | + const skipCount = (pageIndex - 1) * pageSize; | |
| 163 | + const res = await getLabelTemplates( | |
| 164 | + { | |
| 165 | + skipCount, | |
| 166 | + maxResultCount: pageSize, | |
| 167 | + keyword: debouncedKeyword || undefined, | |
| 168 | + locationId: locationFilter !== 'all' ? locationFilter : undefined, | |
| 169 | + labelType: labelTypeFilter !== 'all' ? (labelTypeFilter as any) : undefined, | |
| 170 | + state: stateFilter === 'all' ? undefined : stateFilter === 'true', | |
| 171 | + }, | |
| 172 | + ac.signal, | |
| 173 | + ); | |
| 174 | + | |
| 175 | + setTemplates(res.items ?? []); | |
| 176 | + setTotal(res.totalCount ?? 0); | |
| 177 | + } catch (e: any) { | |
| 178 | + if (e?.name === 'AbortError') return; | |
| 179 | + toast.error('Failed to load label templates.', { | |
| 180 | + description: e?.message ? String(e.message) : 'Please try again.', | |
| 181 | + }); | |
| 182 | + setTemplates([]); | |
| 183 | + setTotal(0); | |
| 184 | + } finally { | |
| 185 | + setLoading(false); | |
| 186 | + } | |
| 187 | + }; | |
| 188 | + | |
| 189 | + run(); | |
| 190 | + return () => abortRef.current?.abort(); | |
| 191 | + }, [debouncedKeyword, locationFilter, labelTypeFilter, stateFilter, pageIndex, pageSize, refreshSeq, viewMode]); | |
| 192 | + | |
| 193 | + const refreshList = () => setRefreshSeq((x) => x + 1); | |
| 46 | 194 | |
| 47 | 195 | const handleNewTemplate = () => { |
| 48 | 196 | setEditingTemplateId(null); |
| 197 | + setInitialTemplate(null); | |
| 49 | 198 | setViewMode('editor'); |
| 50 | 199 | }; |
| 51 | 200 | |
| 52 | - const handleEditTemplate = (id: string) => { | |
| 53 | - setEditingTemplateId(id); | |
| 54 | - setViewMode('editor'); | |
| 201 | + const handleEditTemplate = async (templateCode: string) => { | |
| 202 | + setEditingTemplateId(templateCode); | |
| 203 | + setLoading(true); | |
| 204 | + try { | |
| 205 | + const apiTemplate = await getLabelTemplate(templateCode); | |
| 206 | + // 转换 API 返回的 DTO 到编辑器需要的格式 | |
| 207 | + const editorTemplate: LabelTemplate = { | |
| 208 | + id: apiTemplate.id, | |
| 209 | + name: (apiTemplate.name ?? apiTemplate.templateName ?? '').trim() || '未命名模板', | |
| 210 | + labelType: (apiTemplate.labelType as any) ?? 'PRICE', | |
| 211 | + unit: (apiTemplate.unit as any) ?? 'cm', | |
| 212 | + width: apiTemplate.width ?? 6, | |
| 213 | + height: apiTemplate.height ?? 4, | |
| 214 | + appliedLocation: appliedLocationToEditor(apiTemplate), | |
| 215 | + appliedLocationIds: [...(apiTemplate.appliedLocationIds ?? [])], | |
| 216 | + showRuler: apiTemplate.showRuler ?? true, | |
| 217 | + showGrid: apiTemplate.showGrid ?? true, | |
| 218 | + elements: (apiTemplate.elements ?? []) as LabelTemplate['elements'], | |
| 219 | + }; | |
| 220 | + setInitialTemplate(editorTemplate); | |
| 221 | + setViewMode('editor'); | |
| 222 | + } catch (e: any) { | |
| 223 | + toast.error('Failed to load template.', { | |
| 224 | + description: e?.message ? String(e.message) : 'Please try again.', | |
| 225 | + }); | |
| 226 | + } finally { | |
| 227 | + setLoading(false); | |
| 228 | + } | |
| 55 | 229 | }; |
| 56 | 230 | |
| 57 | 231 | const handleCloseEditor = () => { |
| 58 | 232 | setViewMode('list'); |
| 59 | 233 | setEditingTemplateId(null); |
| 234 | + setInitialTemplate(null); | |
| 235 | + }; | |
| 236 | + | |
| 237 | + const openDelete = (template: LabelTemplateDto) => { | |
| 238 | + setActionsOpenForId(null); | |
| 239 | + setDeletingTemplate(template); | |
| 240 | + setIsDeleteDialogOpen(true); | |
| 60 | 241 | }; |
| 61 | 242 | |
| 62 | 243 | if (viewMode === 'editor') { |
| 63 | - const initialTemplate = | |
| 64 | - editingTemplateId ? getTemplate(editingTemplateId) : null; | |
| 65 | 244 | return ( |
| 66 | 245 | <div className="h-[calc(100vh-8rem)] min-h-[500px] flex flex-col"> |
| 67 | 246 | <LabelTemplateEditor |
| ... | ... | @@ -75,100 +254,290 @@ export function LabelTemplatesView() { |
| 75 | 254 | } |
| 76 | 255 | |
| 77 | 256 | return ( |
| 78 | - <div className="space-y-6"> | |
| 79 | - {/* Top Controls - single row, style consistent with other Label views */} | |
| 80 | - <div className="flex flex-nowrap items-center gap-3"> | |
| 81 | - <Input | |
| 82 | - placeholder="Search" | |
| 83 | - style={{ height: 40, boxSizing: 'border-box' }} | |
| 84 | - className="bg-white border border-gray-300 rounded-md w-40 shrink-0 placeholder:text-gray-500" | |
| 85 | - value={search} | |
| 86 | - onChange={(e) => setSearch(e.target.value)} | |
| 87 | - /> | |
| 88 | - <Select value={locationFilter} onValueChange={setLocationFilter}> | |
| 89 | - <SelectTrigger className="bg-white border border-gray-300 rounded-md w-[200px] shrink-0" style={{ height: 40, boxSizing: 'border-box' }}> | |
| 90 | - <SelectValue placeholder="Location" /> | |
| 91 | - </SelectTrigger> | |
| 92 | - <SelectContent> | |
| 93 | - <SelectItem value="all">All Locations</SelectItem> | |
| 94 | - <SelectItem value="ALL">ALL</SelectItem> | |
| 95 | - <SelectItem value="loc-a">Location A</SelectItem> | |
| 96 | - <SelectItem value="loc-b">Location B</SelectItem> | |
| 97 | - </SelectContent> | |
| 98 | - </Select> | |
| 99 | - <Button | |
| 100 | - className="bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md h-10 px-6 shrink-0 ml-auto" | |
| 101 | - onClick={handleNewTemplate} | |
| 102 | - > | |
| 103 | - New Label Template <Plus className="ml-1 w-4 h-4" /> | |
| 104 | - </Button> | |
| 105 | - </div> | |
| 106 | - | |
| 107 | - {/* Warning Banner */} | |
| 108 | - <div className="text-red-600 font-bold italic text-sm md:text-base"> | |
| 109 | - ***One or more templates have incomplete labels attached to them. | |
| 110 | - <br /> | |
| 111 | - Go to Labels view to see which labels are missing fields. | |
| 257 | + <div className="h-full flex flex-col"> | |
| 258 | + <div className="pb-4"> | |
| 259 | + <div className="flex flex-col gap-4"> | |
| 260 | + <div className="flex flex-nowrap items-center gap-3"> | |
| 261 | + <Input | |
| 262 | + placeholder="Search" | |
| 263 | + value={keyword} | |
| 264 | + onChange={(e) => setKeyword(e.target.value)} | |
| 265 | + style={{ height: 40, boxSizing: 'border-box' }} | |
| 266 | + className="bg-white border border-gray-300 rounded-md w-40 shrink-0 placeholder:text-gray-500" | |
| 267 | + /> | |
| 268 | + <Select value={locationFilter} onValueChange={setLocationFilter}> | |
| 269 | + <SelectTrigger className="bg-white border border-gray-300 rounded-md w-[150px] shrink-0" style={{ height: 40, boxSizing: 'border-box' }}> | |
| 270 | + <SelectValue placeholder="Location" /> | |
| 271 | + </SelectTrigger> | |
| 272 | + <SelectContent> | |
| 273 | + <SelectItem value="all">All Locations</SelectItem> | |
| 274 | + {locations.map((loc) => ( | |
| 275 | + <SelectItem key={loc.id} value={loc.id}> | |
| 276 | + {toDisplay(loc.locationName ?? loc.locationCode ?? loc.id)} | |
| 277 | + </SelectItem> | |
| 278 | + ))} | |
| 279 | + </SelectContent> | |
| 280 | + </Select> | |
| 281 | + <Select value={labelTypeFilter} onValueChange={setLabelTypeFilter}> | |
| 282 | + <SelectTrigger className="bg-white border border-gray-300 rounded-md w-[150px] shrink-0" style={{ height: 40, boxSizing: 'border-box' }}> | |
| 283 | + <SelectValue placeholder="Label Type" /> | |
| 284 | + </SelectTrigger> | |
| 285 | + <SelectContent> | |
| 286 | + <SelectItem value="all">All Types</SelectItem> | |
| 287 | + <SelectItem value="PRICE">PRICE</SelectItem> | |
| 288 | + <SelectItem value="NUTRITION">NUTRITION</SelectItem> | |
| 289 | + <SelectItem value="SHIPPING">SHIPPING</SelectItem> | |
| 290 | + </SelectContent> | |
| 291 | + </Select> | |
| 292 | + <Select value={stateFilter} onValueChange={setStateFilter}> | |
| 293 | + <SelectTrigger className="bg-white border border-gray-300 rounded-md w-[150px] shrink-0" style={{ height: 40, boxSizing: 'border-box' }}> | |
| 294 | + <SelectValue placeholder="State" /> | |
| 295 | + </SelectTrigger> | |
| 296 | + <SelectContent> | |
| 297 | + <SelectItem value="all">All States</SelectItem> | |
| 298 | + <SelectItem value="true">Active</SelectItem> | |
| 299 | + <SelectItem value="false">Inactive</SelectItem> | |
| 300 | + </SelectContent> | |
| 301 | + </Select> | |
| 302 | + <div className="flex-1" /> | |
| 303 | + <Button | |
| 304 | + className="bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md h-10 px-6 shrink-0" | |
| 305 | + onClick={handleNewTemplate} | |
| 306 | + > | |
| 307 | + New Label Template <Plus className="ml-1 w-4 h-4" /> | |
| 308 | + </Button> | |
| 309 | + </div> | |
| 310 | + </div> | |
| 112 | 311 | </div> |
| 113 | 312 | |
| 114 | - {/* Table */} | |
| 115 | - <div className="rounded-md border bg-white shadow-sm"> | |
| 116 | - <Table> | |
| 117 | - <TableHeader> | |
| 118 | - <TableRow className="bg-gray-50 hover:bg-gray-50"> | |
| 119 | - <TableHead className="font-bold text-gray-900 w-[180px]"> | |
| 120 | - Label Template | |
| 121 | - </TableHead> | |
| 122 | - <TableHead className="font-bold text-gray-900 w-[120px]"> | |
| 123 | - Location | |
| 124 | - </TableHead> | |
| 125 | - <TableHead className="font-bold text-gray-900">Contents</TableHead> | |
| 126 | - <TableHead className="font-bold text-gray-900 w-[150px]"> | |
| 127 | - Size | |
| 128 | - </TableHead> | |
| 129 | - <TableHead className="font-bold text-gray-900 w-[100px]"> | |
| 130 | - Actions | |
| 131 | - </TableHead> | |
| 132 | - </TableRow> | |
| 133 | - </TableHeader> | |
| 134 | - <TableBody> | |
| 135 | - {filtered.length === 0 ? ( | |
| 136 | - <TableRow> | |
| 137 | - <TableCell colSpan={5} className="text-center text-gray-500 py-8"> | |
| 138 | - No templates yet. Click "New Label Template" to create one. | |
| 139 | - </TableCell> | |
| 313 | + <div className="flex-1 overflow-auto pt-6"> | |
| 314 | + <div className="rounded-md border bg-white shadow-sm"> | |
| 315 | + <Table> | |
| 316 | + <TableHeader> | |
| 317 | + <TableRow className="bg-gray-50 hover:bg-gray-50"> | |
| 318 | + <TableHead className="font-bold text-gray-900 w-[180px]">Label Template</TableHead> | |
| 319 | + <TableHead className="font-bold text-gray-900 w-[120px]">Template Code</TableHead> | |
| 320 | + <TableHead className="font-bold text-gray-900 w-[120px]">Location</TableHead> | |
| 321 | + <TableHead className="font-bold text-gray-900 w-[100px]">Label Type</TableHead> | |
| 322 | + <TableHead className="font-bold text-gray-900">Contents</TableHead> | |
| 323 | + <TableHead className="font-bold text-gray-900 w-[150px]">Size</TableHead> | |
| 324 | + <TableHead className="font-bold text-gray-900 text-center w-[100px]">Actions</TableHead> | |
| 140 | 325 | </TableRow> |
| 141 | - ) : ( | |
| 142 | - filtered.map((t) => ( | |
| 143 | - <TableRow | |
| 144 | - key={t.id} | |
| 145 | - className="hover:bg-gray-50 cursor-pointer" | |
| 146 | - onClick={() => handleEditTemplate(t.id)} | |
| 147 | - > | |
| 148 | - <TableCell className="font-medium">{t.name}</TableCell> | |
| 149 | - <TableCell>{t.appliedLocation}</TableCell> | |
| 150 | - <TableCell className="text-sm text-gray-600"> | |
| 151 | - {t.elements.length} element(s) | |
| 152 | - </TableCell> | |
| 153 | - <TableCell> | |
| 154 | - {t.width}×{t.height} {t.unit} | |
| 326 | + </TableHeader> | |
| 327 | + <TableBody> | |
| 328 | + {loading ? ( | |
| 329 | + <TableRow> | |
| 330 | + <TableCell colSpan={7} className="text-center text-sm text-gray-500 py-10"> | |
| 331 | + Loading... | |
| 155 | 332 | </TableCell> |
| 156 | - <TableCell onClick={(e) => e.stopPropagation()}> | |
| 157 | - <Button | |
| 158 | - variant="outline" | |
| 159 | - size="sm" | |
| 160 | - onClick={() => handleEditTemplate(t.id)} | |
| 161 | - > | |
| 162 | - <Pencil className="w-3 h-3 mr-1" /> | |
| 163 | - Edit | |
| 164 | - </Button> | |
| 333 | + </TableRow> | |
| 334 | + ) : templates.length === 0 ? ( | |
| 335 | + <TableRow> | |
| 336 | + <TableCell colSpan={7} className="text-center text-sm text-gray-500 py-10"> | |
| 337 | + No templates yet. Click "New Label Template" to create one. | |
| 165 | 338 | </TableCell> |
| 166 | 339 | </TableRow> |
| 167 | - )) | |
| 168 | - )} | |
| 169 | - </TableBody> | |
| 170 | - </Table> | |
| 340 | + ) : ( | |
| 341 | + templates.map((t) => ( | |
| 342 | + <TableRow key={t.id} className="hover:bg-gray-50"> | |
| 343 | + <TableCell className="font-medium whitespace-nowrap overflow-hidden text-ellipsis max-w-[180px]"> | |
| 344 | + {templateListDisplayName(t)} | |
| 345 | + </TableCell> | |
| 346 | + <TableCell className="text-gray-600 whitespace-nowrap overflow-hidden text-ellipsis max-w-[140px]"> | |
| 347 | + {templateListDisplayCode(t)} | |
| 348 | + </TableCell> | |
| 349 | + <TableCell className="whitespace-nowrap overflow-hidden text-ellipsis max-w-[140px]"> | |
| 350 | + {toDisplay(templateListDisplayLocation(t, locations))} | |
| 351 | + </TableCell> | |
| 352 | + <TableCell className="whitespace-nowrap">{toDisplay(t.labelType)}</TableCell> | |
| 353 | + <TableCell className="text-sm text-gray-600 whitespace-nowrap"> | |
| 354 | + {templateListContentsCount(t)} element(s) | |
| 355 | + </TableCell> | |
| 356 | + <TableCell className="whitespace-nowrap overflow-hidden text-ellipsis max-w-[160px]"> | |
| 357 | + {templateListDisplaySize(t)} | |
| 358 | + </TableCell> | |
| 359 | + <TableCell className="text-center"> | |
| 360 | + <Popover | |
| 361 | + open={actionsOpenForId === t.id} | |
| 362 | + onOpenChange={(open) => setActionsOpenForId(open ? t.id : null)} | |
| 363 | + > | |
| 364 | + <PopoverTrigger asChild> | |
| 365 | + <Button | |
| 366 | + type="button" | |
| 367 | + variant="ghost" | |
| 368 | + size="icon" | |
| 369 | + className="h-8 w-8" | |
| 370 | + aria-label="Row actions" | |
| 371 | + > | |
| 372 | + <MoreHorizontal className="h-4 w-4 text-gray-500" /> | |
| 373 | + </Button> | |
| 374 | + </PopoverTrigger> | |
| 375 | + <PopoverContent align="end" className="w-40 p-1"> | |
| 376 | + <Button | |
| 377 | + type="button" | |
| 378 | + variant="ghost" | |
| 379 | + className="w-full justify-start gap-2 h-9 px-2 font-normal" | |
| 380 | + onClick={() => handleEditTemplate(t.id)} | |
| 381 | + > | |
| 382 | + <Pencil className="w-4 h-4" /> | |
| 383 | + Edit | |
| 384 | + </Button> | |
| 385 | + <Button | |
| 386 | + type="button" | |
| 387 | + variant="ghost" | |
| 388 | + className="w-full justify-start h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50" | |
| 389 | + onClick={() => openDelete(t)} | |
| 390 | + > | |
| 391 | + Delete | |
| 392 | + </Button> | |
| 393 | + </PopoverContent> | |
| 394 | + </Popover> | |
| 395 | + </TableCell> | |
| 396 | + </TableRow> | |
| 397 | + )) | |
| 398 | + )} | |
| 399 | + </TableBody> | |
| 400 | + </Table> | |
| 401 | + </div> | |
| 171 | 402 | </div> |
| 403 | + | |
| 404 | + <div className="pt-4"> | |
| 405 | + <div className="flex items-center justify-between text-sm text-gray-600"> | |
| 406 | + <div> | |
| 407 | + Showing {total === 0 ? 0 : (pageIndex - 1) * pageSize + 1}- | |
| 408 | + {Math.min(pageIndex * pageSize, total)} of {total} | |
| 409 | + </div> | |
| 410 | + <div className="flex items-center gap-3"> | |
| 411 | + <Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}> | |
| 412 | + <SelectTrigger className="w-[110px] h-9 rounded-md border border-gray-300 bg-white text-gray-900"> | |
| 413 | + <SelectValue /> | |
| 414 | + </SelectTrigger> | |
| 415 | + <SelectContent> | |
| 416 | + {[10, 20, 50].map((n) => ( | |
| 417 | + <SelectItem key={n} value={String(n)}> | |
| 418 | + {n} / page | |
| 419 | + </SelectItem> | |
| 420 | + ))} | |
| 421 | + </SelectContent> | |
| 422 | + </Select> | |
| 423 | + <Pagination className="mx-0 w-auto justify-end"> | |
| 424 | + <PaginationContent> | |
| 425 | + <PaginationItem> | |
| 426 | + <PaginationPrevious | |
| 427 | + href="#" | |
| 428 | + size="default" | |
| 429 | + onClick={(e) => { | |
| 430 | + e.preventDefault(); | |
| 431 | + setPageIndex((p) => Math.max(1, p - 1)); | |
| 432 | + }} | |
| 433 | + aria-disabled={pageIndex <= 1} | |
| 434 | + className={pageIndex <= 1 ? "pointer-events-none opacity-50" : ""} | |
| 435 | + /> | |
| 436 | + </PaginationItem> | |
| 437 | + <PaginationItem> | |
| 438 | + <PaginationLink | |
| 439 | + href="#" | |
| 440 | + isActive | |
| 441 | + size="default" | |
| 442 | + onClick={(e) => e.preventDefault()} | |
| 443 | + > | |
| 444 | + Page {pageIndex} / {totalPages} | |
| 445 | + </PaginationLink> | |
| 446 | + </PaginationItem> | |
| 447 | + <PaginationItem> | |
| 448 | + <PaginationNext | |
| 449 | + href="#" | |
| 450 | + size="default" | |
| 451 | + onClick={(e) => { | |
| 452 | + e.preventDefault(); | |
| 453 | + setPageIndex((p) => Math.min(totalPages, p + 1)); | |
| 454 | + }} | |
| 455 | + aria-disabled={pageIndex >= totalPages} | |
| 456 | + className={pageIndex >= totalPages ? "pointer-events-none opacity-50" : ""} | |
| 457 | + /> | |
| 458 | + </PaginationItem> | |
| 459 | + </PaginationContent> | |
| 460 | + </Pagination> | |
| 461 | + </div> | |
| 462 | + </div> | |
| 463 | + </div> | |
| 464 | + | |
| 465 | + <DeleteLabelTemplateDialog | |
| 466 | + open={isDeleteDialogOpen} | |
| 467 | + template={deletingTemplate} | |
| 468 | + onOpenChange={(open) => { | |
| 469 | + setIsDeleteDialogOpen(open); | |
| 470 | + if (!open) setDeletingTemplate(null); | |
| 471 | + }} | |
| 472 | + onDeleted={refreshList} | |
| 473 | + /> | |
| 172 | 474 | </div> |
| 173 | 475 | ); |
| 174 | 476 | } |
| 477 | + | |
| 478 | +function DeleteLabelTemplateDialog({ | |
| 479 | + open, | |
| 480 | + template, | |
| 481 | + onOpenChange, | |
| 482 | + onDeleted, | |
| 483 | +}: { | |
| 484 | + open: boolean; | |
| 485 | + template: LabelTemplateDto | null; | |
| 486 | + onOpenChange: (open: boolean) => void; | |
| 487 | + onDeleted: () => void; | |
| 488 | +}) { | |
| 489 | + const [submitting, setSubmitting] = useState(false); | |
| 490 | + | |
| 491 | + const name = useMemo(() => { | |
| 492 | + const n = (template?.templateName ?? template?.name ?? "").trim(); | |
| 493 | + return n || (template?.templateCode ?? template?.id ?? "").trim() || "this template"; | |
| 494 | + }, [template]); | |
| 495 | + | |
| 496 | + const submit = async () => { | |
| 497 | + if (!template?.id) return; | |
| 498 | + setSubmitting(true); | |
| 499 | + try { | |
| 500 | + await deleteLabelTemplate(template.id); | |
| 501 | + toast.success("Label template deleted.", { | |
| 502 | + description: "The label template has been removed successfully.", | |
| 503 | + }); | |
| 504 | + onOpenChange(false); | |
| 505 | + onDeleted(); | |
| 506 | + } catch (e: any) { | |
| 507 | + toast.error("Failed to delete label template.", { | |
| 508 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 509 | + }); | |
| 510 | + } finally { | |
| 511 | + setSubmitting(false); | |
| 512 | + } | |
| 513 | + }; | |
| 514 | + | |
| 515 | + return ( | |
| 516 | + <Dialog open={open} onOpenChange={onOpenChange}> | |
| 517 | + <DialogContent className="sm:max-w-none" style={{ width: "30%" }}> | |
| 518 | + <DialogHeader> | |
| 519 | + <DialogTitle>Delete Label Template</DialogTitle> | |
| 520 | + <DialogDescription>This action cannot be undone.</DialogDescription> | |
| 521 | + </DialogHeader> | |
| 522 | + | |
| 523 | + <div className="text-sm text-gray-700"> | |
| 524 | + Are you sure you want to delete <span className="font-medium">{name}</span>? | |
| 525 | + </div> | |
| 526 | + | |
| 527 | + <DialogFooter className="flex-row flex-wrap justify-end"> | |
| 528 | + <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}> | |
| 529 | + Cancel | |
| 530 | + </Button> | |
| 531 | + <Button | |
| 532 | + className="min-w-24" | |
| 533 | + variant="destructive" | |
| 534 | + disabled={submitting} | |
| 535 | + onClick={submit} | |
| 536 | + > | |
| 537 | + {submitting ? "Deleting..." : "Delete"} | |
| 538 | + </Button> | |
| 539 | + </DialogFooter> | |
| 540 | + </DialogContent> | |
| 541 | + </Dialog> | |
| 542 | + ); | |
| 543 | +} | ... | ... |
美国版/Food Labeling Management Platform/src/components/labels/LabelTypesView.tsx
| 1 | -import React from 'react'; | |
| 1 | +import React, { useEffect, useMemo, useRef, useState } from 'react'; | |
| 2 | 2 | import { |
| 3 | 3 | Table, |
| 4 | 4 | TableBody, |
| ... | ... | @@ -16,89 +16,640 @@ import { |
| 16 | 16 | SelectTrigger, |
| 17 | 17 | SelectValue, |
| 18 | 18 | } from "../ui/select"; |
| 19 | -import { Plus } from "lucide-react"; | |
| 19 | +import { | |
| 20 | + Dialog, | |
| 21 | + DialogContent, | |
| 22 | + DialogDescription, | |
| 23 | + DialogFooter, | |
| 24 | + DialogHeader, | |
| 25 | + DialogTitle, | |
| 26 | +} from "../ui/dialog"; | |
| 27 | +import { Label } from "../ui/label"; | |
| 28 | +import { Switch } from "../ui/switch"; | |
| 29 | +import { Badge } from "../ui/badge"; | |
| 30 | +import { Plus, Edit, MoreHorizontal } from "lucide-react"; | |
| 31 | +import { toast } from "sonner"; | |
| 32 | +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | |
| 33 | +import { | |
| 34 | + Pagination, | |
| 35 | + PaginationContent, | |
| 36 | + PaginationItem, | |
| 37 | + PaginationLink, | |
| 38 | + PaginationNext, | |
| 39 | + PaginationPrevious, | |
| 40 | +} from "../ui/pagination"; | |
| 41 | +import { | |
| 42 | + getLabelTypes, | |
| 43 | + getLabelType, | |
| 44 | + createLabelType, | |
| 45 | + updateLabelType, | |
| 46 | + deleteLabelType, | |
| 47 | +} from "../../services/labelTypeService"; | |
| 48 | +import type { | |
| 49 | + LabelTypeDto, | |
| 50 | + LabelTypeCreateInput, | |
| 51 | + LabelTypeUpdateInput, | |
| 52 | +} from "../../types/labelType"; | |
| 53 | + | |
| 54 | +function toDisplay(v: string | null | undefined): string { | |
| 55 | + const s = (v ?? "").trim(); | |
| 56 | + return s ? s : "None"; | |
| 57 | +} | |
| 20 | 58 | |
| 21 | 59 | export function LabelTypesView() { |
| 22 | - const types = [ | |
| 23 | - { | |
| 24 | - id: 1, | |
| 25 | - type: 'Defrost', | |
| 26 | - count: 54, | |
| 27 | - lastEdited: '2025.12.03.11:45', | |
| 28 | - }, | |
| 29 | - { | |
| 30 | - id: 2, | |
| 31 | - type: 'Thawed', | |
| 32 | - count: 33, | |
| 33 | - lastEdited: '2025.12.03.11:45', | |
| 34 | - }, | |
| 35 | - { | |
| 36 | - id: 3, | |
| 37 | - type: 'Opened', | |
| 38 | - count: 44, | |
| 39 | - lastEdited: '2025.12.03.11:45', | |
| 40 | - }, | |
| 41 | - { | |
| 42 | - id: 4, | |
| 43 | - type: 'Preped', | |
| 44 | - count: 17, | |
| 45 | - lastEdited: '2025.12.03.11:45', | |
| 46 | - }, | |
| 47 | - { | |
| 48 | - id: 5, | |
| 49 | - type: 'Heated', | |
| 50 | - count: 67, | |
| 51 | - lastEdited: '2025.12.03.11:45', | |
| 52 | - }, | |
| 53 | - ]; | |
| 60 | + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); | |
| 61 | + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); | |
| 62 | + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); | |
| 63 | + const [editingType, setEditingType] = useState<LabelTypeDto | null>(null); | |
| 64 | + const [deletingType, setDeletingType] = useState<LabelTypeDto | null>(null); | |
| 65 | + const [types, setTypes] = useState<LabelTypeDto[]>([]); | |
| 66 | + const [loading, setLoading] = useState(false); | |
| 67 | + const [total, setTotal] = useState(0); | |
| 68 | + const [refreshSeq, setRefreshSeq] = useState(0); | |
| 69 | + const [actionsOpenForId, setActionsOpenForId] = useState<string | null>(null); | |
| 70 | + | |
| 71 | + const [keyword, setKeyword] = useState(""); | |
| 72 | + const [stateFilter, setStateFilter] = useState<string>("all"); | |
| 73 | + | |
| 74 | + const [pageIndex, setPageIndex] = useState(1); | |
| 75 | + const [pageSize, setPageSize] = useState(10); | |
| 76 | + | |
| 77 | + const abortRef = useRef<AbortController | null>(null); | |
| 78 | + const keywordTimerRef = useRef<number | null>(null); | |
| 79 | + const [debouncedKeyword, setDebouncedKeyword] = useState(""); | |
| 80 | + | |
| 81 | + useEffect(() => { | |
| 82 | + if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); | |
| 83 | + keywordTimerRef.current = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300); | |
| 84 | + return () => { | |
| 85 | + if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); | |
| 86 | + }; | |
| 87 | + }, [keyword]); | |
| 88 | + | |
| 89 | + const totalPages = Math.max(1, Math.ceil(total / pageSize)); | |
| 90 | + | |
| 91 | + useEffect(() => { | |
| 92 | + setPageIndex(1); | |
| 93 | + }, [debouncedKeyword, stateFilter, pageSize]); | |
| 94 | + | |
| 95 | + useEffect(() => { | |
| 96 | + const run = async () => { | |
| 97 | + abortRef.current?.abort(); | |
| 98 | + const ac = new AbortController(); | |
| 99 | + abortRef.current = ac; | |
| 100 | + | |
| 101 | + setLoading(true); | |
| 102 | + try { | |
| 103 | + const skipCount = (pageIndex - 1) * pageSize; | |
| 104 | + const res = await getLabelTypes( | |
| 105 | + { | |
| 106 | + skipCount, | |
| 107 | + maxResultCount: pageSize, | |
| 108 | + keyword: debouncedKeyword || undefined, | |
| 109 | + state: stateFilter === "all" ? undefined : stateFilter === "true", | |
| 110 | + }, | |
| 111 | + ac.signal, | |
| 112 | + ); | |
| 113 | + | |
| 114 | + setTypes(res.items ?? []); | |
| 115 | + setTotal(res.totalCount ?? 0); | |
| 116 | + } catch (e: any) { | |
| 117 | + if (e?.name === "AbortError") return; | |
| 118 | + toast.error("Failed to load label types.", { | |
| 119 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 120 | + }); | |
| 121 | + setTypes([]); | |
| 122 | + setTotal(0); | |
| 123 | + } finally { | |
| 124 | + setLoading(false); | |
| 125 | + } | |
| 126 | + }; | |
| 127 | + | |
| 128 | + run(); | |
| 129 | + return () => abortRef.current?.abort(); | |
| 130 | + }, [debouncedKeyword, stateFilter, pageIndex, pageSize, refreshSeq]); | |
| 131 | + | |
| 132 | + const refreshList = () => setRefreshSeq((x) => x + 1); | |
| 133 | + | |
| 134 | + const openEdit = (type: LabelTypeDto) => { | |
| 135 | + setActionsOpenForId(null); | |
| 136 | + setEditingType(type); | |
| 137 | + setIsEditDialogOpen(true); | |
| 138 | + }; | |
| 139 | + | |
| 140 | + const openDelete = (type: LabelTypeDto) => { | |
| 141 | + setActionsOpenForId(null); | |
| 142 | + setDeletingType(type); | |
| 143 | + setIsDeleteDialogOpen(true); | |
| 144 | + }; | |
| 54 | 145 | |
| 55 | 146 | return ( |
| 56 | - <div className="space-y-6"> | |
| 57 | - {/* Search, Location, New Label Type - single row */} | |
| 58 | - <div className="flex flex-nowrap items-center gap-3"> | |
| 59 | - <Input | |
| 60 | - placeholder="Search" | |
| 61 | - style={{ height: 40, boxSizing: 'border-box' }} | |
| 62 | - className="bg-white border border-gray-300 rounded-md w-40 shrink-0 placeholder:text-gray-500" | |
| 63 | - /> | |
| 64 | - <span className="text-sm font-medium text-gray-900 whitespace-nowrap shrink-0">Search</span> | |
| 65 | - <Select defaultValue="all"> | |
| 66 | - <SelectTrigger className="bg-white border border-gray-300 rounded-md w-[150px] shrink-0" style={{ height: 40, boxSizing: 'border-box' }}> | |
| 67 | - <SelectValue placeholder="Location" /> | |
| 68 | - </SelectTrigger> | |
| 69 | - <SelectContent> | |
| 70 | - <SelectItem value="all">all</SelectItem> | |
| 71 | - <SelectItem value="loc-a">Location A</SelectItem> | |
| 72 | - <SelectItem value="loc-b">Location B</SelectItem> | |
| 73 | - </SelectContent> | |
| 74 | - </Select> | |
| 75 | - <span className="text-sm font-medium text-gray-900 whitespace-nowrap shrink-0">Location</span> | |
| 76 | - <Button className="bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md h-10 px-6 shrink-0 ml-auto"> | |
| 77 | - New Label Type <Plus className="ml-1 h-4 w-4" /> | |
| 78 | - </Button> | |
| 147 | + <div className="h-full flex flex-col"> | |
| 148 | + <div className="pb-4"> | |
| 149 | + <div className="flex flex-col gap-4"> | |
| 150 | + <div className="flex flex-nowrap items-center gap-3"> | |
| 151 | + <Input | |
| 152 | + placeholder="Search" | |
| 153 | + value={keyword} | |
| 154 | + onChange={(e) => setKeyword(e.target.value)} | |
| 155 | + style={{ height: 40, boxSizing: 'border-box' }} | |
| 156 | + className="bg-white border border-gray-300 rounded-md w-40 shrink-0 placeholder:text-gray-500" | |
| 157 | + /> | |
| 158 | + <Select value={stateFilter} onValueChange={setStateFilter}> | |
| 159 | + <SelectTrigger className="bg-white border border-gray-300 rounded-md w-[150px] shrink-0" style={{ height: 40, boxSizing: 'border-box' }}> | |
| 160 | + <SelectValue placeholder="State" /> | |
| 161 | + </SelectTrigger> | |
| 162 | + <SelectContent> | |
| 163 | + <SelectItem value="all">All States</SelectItem> | |
| 164 | + <SelectItem value="true">Active</SelectItem> | |
| 165 | + <SelectItem value="false">Inactive</SelectItem> | |
| 166 | + </SelectContent> | |
| 167 | + </Select> | |
| 168 | + <div className="flex-1" /> | |
| 169 | + <Button | |
| 170 | + className="bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md h-10 px-6 shrink-0" | |
| 171 | + onClick={() => setIsCreateDialogOpen(true)} | |
| 172 | + > | |
| 173 | + New Label Type <Plus className="ml-1 h-4 w-4" /> | |
| 174 | + </Button> | |
| 175 | + </div> | |
| 176 | + </div> | |
| 79 | 177 | </div> |
| 80 | 178 | |
| 81 | - {/* Table */} | |
| 82 | - <div className="rounded-md border bg-white shadow-sm"> | |
| 83 | - <Table> | |
| 84 | - <TableHeader> | |
| 85 | - <TableRow className="bg-gray-50 hover:bg-gray-50"> | |
| 86 | - <TableHead className="font-bold text-gray-900 w-[250px]">Label Types</TableHead> | |
| 87 | - <TableHead className="font-bold text-gray-900 w-[200px]">No. of Labels</TableHead> | |
| 88 | - <TableHead className="font-bold text-gray-900">Last Edited</TableHead> | |
| 89 | - </TableRow> | |
| 90 | - </TableHeader> | |
| 91 | - <TableBody> | |
| 92 | - {types.map((item) => ( | |
| 93 | - <TableRow key={item.id} className="hover:bg-gray-50"> | |
| 94 | - <TableCell className="font-medium">{item.type}</TableCell> | |
| 95 | - <TableCell className="font-numeric">{item.count}</TableCell> | |
| 96 | - <TableCell className="text-gray-500 tabular-nums font-numeric">{item.lastEdited}</TableCell> | |
| 179 | + <div className="flex-1 overflow-auto pt-6"> | |
| 180 | + <div className="rounded-md border bg-white shadow-sm"> | |
| 181 | + <Table> | |
| 182 | + <TableHeader> | |
| 183 | + <TableRow className="bg-gray-50 hover:bg-gray-50"> | |
| 184 | + <TableHead className="font-bold text-gray-900 w-[250px]">Label Types</TableHead> | |
| 185 | + <TableHead className="font-bold text-gray-900 w-[200px]">Type Code</TableHead> | |
| 186 | + <TableHead className="font-bold text-gray-900 w-[100px]">State</TableHead> | |
| 187 | + <TableHead className="font-bold text-gray-900 w-[100px]">Order</TableHead> | |
| 188 | + <TableHead className="font-bold text-gray-900">Last Edited</TableHead> | |
| 189 | + <TableHead className="font-bold text-gray-900 text-center w-[100px]">Actions</TableHead> | |
| 97 | 190 | </TableRow> |
| 98 | - ))} | |
| 99 | - </TableBody> | |
| 100 | - </Table> | |
| 191 | + </TableHeader> | |
| 192 | + <TableBody> | |
| 193 | + {loading ? ( | |
| 194 | + <TableRow> | |
| 195 | + <TableCell colSpan={6} className="text-center text-sm text-gray-500 py-10"> | |
| 196 | + Loading... | |
| 197 | + </TableCell> | |
| 198 | + </TableRow> | |
| 199 | + ) : types.length === 0 ? ( | |
| 200 | + <TableRow> | |
| 201 | + <TableCell colSpan={6} className="text-center text-sm text-gray-500 py-10"> | |
| 202 | + No results. | |
| 203 | + </TableCell> | |
| 204 | + </TableRow> | |
| 205 | + ) : ( | |
| 206 | + types.map((item) => ( | |
| 207 | + <TableRow key={item.id} className="hover:bg-gray-50"> | |
| 208 | + <TableCell className="font-medium">{toDisplay(item.typeName)}</TableCell> | |
| 209 | + <TableCell className="text-gray-600">{toDisplay(item.typeCode)}</TableCell> | |
| 210 | + <TableCell> | |
| 211 | + <Badge className={item.state ? "bg-green-600" : "bg-gray-400"}> | |
| 212 | + {item.state ? "Active" : "Inactive"} | |
| 213 | + </Badge> | |
| 214 | + </TableCell> | |
| 215 | + <TableCell className="font-numeric">{item.orderNum ?? "None"}</TableCell> | |
| 216 | + <TableCell className="text-gray-500 tabular-nums font-numeric"> | |
| 217 | + {item.creationTime ? new Date(item.creationTime).toLocaleString() : "None"} | |
| 218 | + </TableCell> | |
| 219 | + <TableCell className="text-center"> | |
| 220 | + <Popover | |
| 221 | + open={actionsOpenForId === item.id} | |
| 222 | + onOpenChange={(open) => setActionsOpenForId(open ? item.id : null)} | |
| 223 | + > | |
| 224 | + <PopoverTrigger asChild> | |
| 225 | + <Button | |
| 226 | + type="button" | |
| 227 | + variant="ghost" | |
| 228 | + size="icon" | |
| 229 | + className="h-8 w-8" | |
| 230 | + aria-label="Row actions" | |
| 231 | + > | |
| 232 | + <MoreHorizontal className="h-4 w-4 text-gray-500" /> | |
| 233 | + </Button> | |
| 234 | + </PopoverTrigger> | |
| 235 | + <PopoverContent align="end" className="w-40 p-1"> | |
| 236 | + <Button | |
| 237 | + type="button" | |
| 238 | + variant="ghost" | |
| 239 | + className="w-full justify-start gap-2 h-9 px-2 font-normal" | |
| 240 | + onClick={() => openEdit(item)} | |
| 241 | + > | |
| 242 | + <Edit className="w-4 h-4" /> | |
| 243 | + Edit | |
| 244 | + </Button> | |
| 245 | + <Button | |
| 246 | + type="button" | |
| 247 | + variant="ghost" | |
| 248 | + className="w-full justify-start h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50" | |
| 249 | + onClick={() => openDelete(item)} | |
| 250 | + > | |
| 251 | + Delete | |
| 252 | + </Button> | |
| 253 | + </PopoverContent> | |
| 254 | + </Popover> | |
| 255 | + </TableCell> | |
| 256 | + </TableRow> | |
| 257 | + )) | |
| 258 | + )} | |
| 259 | + </TableBody> | |
| 260 | + </Table> | |
| 261 | + </div> | |
| 101 | 262 | </div> |
| 263 | + | |
| 264 | + <div className="pt-4"> | |
| 265 | + <div className="flex items-center justify-between text-sm text-gray-600"> | |
| 266 | + <div> | |
| 267 | + Showing {total === 0 ? 0 : (pageIndex - 1) * pageSize + 1}- | |
| 268 | + {Math.min(pageIndex * pageSize, total)} of {total} | |
| 269 | + </div> | |
| 270 | + <div className="flex items-center gap-3"> | |
| 271 | + <Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}> | |
| 272 | + <SelectTrigger className="w-[110px] h-9 rounded-md border border-gray-300 bg-white text-gray-900"> | |
| 273 | + <SelectValue /> | |
| 274 | + </SelectTrigger> | |
| 275 | + <SelectContent> | |
| 276 | + {[10, 20, 50].map((n) => ( | |
| 277 | + <SelectItem key={n} value={String(n)}> | |
| 278 | + {n} / page | |
| 279 | + </SelectItem> | |
| 280 | + ))} | |
| 281 | + </SelectContent> | |
| 282 | + </Select> | |
| 283 | + <Pagination className="mx-0 w-auto justify-end"> | |
| 284 | + <PaginationContent> | |
| 285 | + <PaginationItem> | |
| 286 | + <PaginationPrevious | |
| 287 | + href="#" | |
| 288 | + size="default" | |
| 289 | + onClick={(e) => { | |
| 290 | + e.preventDefault(); | |
| 291 | + setPageIndex((p) => Math.max(1, p - 1)); | |
| 292 | + }} | |
| 293 | + aria-disabled={pageIndex <= 1} | |
| 294 | + className={pageIndex <= 1 ? "pointer-events-none opacity-50" : ""} | |
| 295 | + /> | |
| 296 | + </PaginationItem> | |
| 297 | + <PaginationItem> | |
| 298 | + <PaginationLink | |
| 299 | + href="#" | |
| 300 | + isActive | |
| 301 | + size="default" | |
| 302 | + onClick={(e) => e.preventDefault()} | |
| 303 | + > | |
| 304 | + Page {pageIndex} / {totalPages} | |
| 305 | + </PaginationLink> | |
| 306 | + </PaginationItem> | |
| 307 | + <PaginationItem> | |
| 308 | + <PaginationNext | |
| 309 | + href="#" | |
| 310 | + size="default" | |
| 311 | + onClick={(e) => { | |
| 312 | + e.preventDefault(); | |
| 313 | + setPageIndex((p) => Math.min(totalPages, p + 1)); | |
| 314 | + }} | |
| 315 | + aria-disabled={pageIndex >= totalPages} | |
| 316 | + className={pageIndex >= totalPages ? "pointer-events-none opacity-50" : ""} | |
| 317 | + /> | |
| 318 | + </PaginationItem> | |
| 319 | + </PaginationContent> | |
| 320 | + </Pagination> | |
| 321 | + </div> | |
| 322 | + </div> | |
| 323 | + </div> | |
| 324 | + | |
| 325 | + <CreateLabelTypeDialog | |
| 326 | + open={isCreateDialogOpen} | |
| 327 | + onOpenChange={setIsCreateDialogOpen} | |
| 328 | + onCreated={() => { | |
| 329 | + setPageIndex(1); | |
| 330 | + refreshList(); | |
| 331 | + }} | |
| 332 | + /> | |
| 333 | + | |
| 334 | + <EditLabelTypeDialog | |
| 335 | + open={isEditDialogOpen} | |
| 336 | + type={editingType} | |
| 337 | + onOpenChange={(open) => { | |
| 338 | + setIsEditDialogOpen(open); | |
| 339 | + if (!open) setEditingType(null); | |
| 340 | + }} | |
| 341 | + onUpdated={refreshList} | |
| 342 | + /> | |
| 343 | + | |
| 344 | + <DeleteLabelTypeDialog | |
| 345 | + open={isDeleteDialogOpen} | |
| 346 | + type={deletingType} | |
| 347 | + onOpenChange={(open) => { | |
| 348 | + setIsDeleteDialogOpen(open); | |
| 349 | + if (!open) setDeletingType(null); | |
| 350 | + }} | |
| 351 | + onDeleted={refreshList} | |
| 352 | + /> | |
| 102 | 353 | </div> |
| 103 | 354 | ); |
| 104 | 355 | } |
| 356 | + | |
| 357 | +function CreateLabelTypeDialog({ | |
| 358 | + open, | |
| 359 | + onOpenChange, | |
| 360 | + onCreated, | |
| 361 | +}: { | |
| 362 | + open: boolean; | |
| 363 | + onOpenChange: (open: boolean) => void; | |
| 364 | + onCreated: () => void; | |
| 365 | +}) { | |
| 366 | + const [submitting, setSubmitting] = useState(false); | |
| 367 | + const [form, setForm] = useState<LabelTypeCreateInput>({ | |
| 368 | + typeCode: "", | |
| 369 | + typeName: "", | |
| 370 | + state: true, | |
| 371 | + orderNum: null, | |
| 372 | + }); | |
| 373 | + | |
| 374 | + const resetForm = () => { | |
| 375 | + setForm({ | |
| 376 | + typeCode: "", | |
| 377 | + typeName: "", | |
| 378 | + state: true, | |
| 379 | + orderNum: null, | |
| 380 | + }); | |
| 381 | + }; | |
| 382 | + | |
| 383 | + useEffect(() => { | |
| 384 | + if (!open) { | |
| 385 | + resetForm(); | |
| 386 | + } | |
| 387 | + }, [open]); | |
| 388 | + | |
| 389 | + const submit = async () => { | |
| 390 | + if (!form.typeCode.trim() || !form.typeName.trim()) { | |
| 391 | + toast.error("Validation failed", { | |
| 392 | + description: "Type Code and Type Name are required.", | |
| 393 | + }); | |
| 394 | + return; | |
| 395 | + } | |
| 396 | + | |
| 397 | + setSubmitting(true); | |
| 398 | + try { | |
| 399 | + await createLabelType(form); | |
| 400 | + toast.success("Label type created.", { | |
| 401 | + description: "The label type has been created successfully.", | |
| 402 | + }); | |
| 403 | + onOpenChange(false); | |
| 404 | + onCreated(); | |
| 405 | + } catch (e: any) { | |
| 406 | + toast.error("Failed to create label type.", { | |
| 407 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 408 | + }); | |
| 409 | + } finally { | |
| 410 | + setSubmitting(false); | |
| 411 | + } | |
| 412 | + }; | |
| 413 | + | |
| 414 | + return ( | |
| 415 | + <Dialog open={open} onOpenChange={onOpenChange}> | |
| 416 | + <DialogContent className="sm:max-w-[600px]"> | |
| 417 | + <DialogHeader> | |
| 418 | + <DialogTitle>Add New Label Type</DialogTitle> | |
| 419 | + <DialogDescription> | |
| 420 | + Enter the details for the new label type. | |
| 421 | + </DialogDescription> | |
| 422 | + </DialogHeader> | |
| 423 | + | |
| 424 | + <div className="grid gap-4 py-4"> | |
| 425 | + <div className="grid grid-cols-2 gap-4"> | |
| 426 | + <div className="space-y-2"> | |
| 427 | + <Label>Type Code *</Label> | |
| 428 | + <Input | |
| 429 | + placeholder="e.g. TYPE_DEFROST" | |
| 430 | + value={form.typeCode} | |
| 431 | + onChange={(e) => setForm((p) => ({ ...p, typeCode: e.target.value }))} | |
| 432 | + /> | |
| 433 | + </div> | |
| 434 | + <div className="space-y-2"> | |
| 435 | + <Label>Type Name *</Label> | |
| 436 | + <Input | |
| 437 | + placeholder="e.g. Defrost" | |
| 438 | + value={form.typeName} | |
| 439 | + onChange={(e) => setForm((p) => ({ ...p, typeName: e.target.value }))} | |
| 440 | + /> | |
| 441 | + </div> | |
| 442 | + </div> | |
| 443 | + | |
| 444 | + <div className="grid grid-cols-2 gap-4"> | |
| 445 | + <div className="space-y-2"> | |
| 446 | + <Label>Order</Label> | |
| 447 | + <Input | |
| 448 | + type="number" | |
| 449 | + placeholder="e.g. 1" | |
| 450 | + value={form.orderNum ?? ""} | |
| 451 | + onChange={(e) => setForm((p) => ({ ...p, orderNum: e.target.value ? Number(e.target.value) : null }))} | |
| 452 | + /> | |
| 453 | + </div> | |
| 454 | + <div className="flex items-center justify-between border border-gray-200 rounded-md px-3 bg-white" style={{ height: 40 }}> | |
| 455 | + <div className="text-sm font-medium text-gray-900">Enabled</div> | |
| 456 | + <Switch checked={form.state} onCheckedChange={(checked) => setForm((p) => ({ ...p, state: checked }))} /> | |
| 457 | + </div> | |
| 458 | + </div> | |
| 459 | + </div> | |
| 460 | + | |
| 461 | + <DialogFooter> | |
| 462 | + <Button variant="outline" onClick={() => onOpenChange(false)}> | |
| 463 | + Cancel | |
| 464 | + </Button> | |
| 465 | + <Button disabled={submitting} onClick={submit}> | |
| 466 | + {submitting ? "Creating..." : "Create"} | |
| 467 | + </Button> | |
| 468 | + </DialogFooter> | |
| 469 | + </DialogContent> | |
| 470 | + </Dialog> | |
| 471 | + ); | |
| 472 | +} | |
| 473 | + | |
| 474 | +function EditLabelTypeDialog({ | |
| 475 | + open, | |
| 476 | + type, | |
| 477 | + onOpenChange, | |
| 478 | + onUpdated, | |
| 479 | +}: { | |
| 480 | + open: boolean; | |
| 481 | + type: LabelTypeDto | null; | |
| 482 | + onOpenChange: (open: boolean) => void; | |
| 483 | + onUpdated: () => void; | |
| 484 | +}) { | |
| 485 | + const [submitting, setSubmitting] = useState(false); | |
| 486 | + const [form, setForm] = useState<LabelTypeUpdateInput>({ | |
| 487 | + typeCode: "", | |
| 488 | + typeName: "", | |
| 489 | + state: true, | |
| 490 | + orderNum: null, | |
| 491 | + }); | |
| 492 | + | |
| 493 | + useEffect(() => { | |
| 494 | + if (open && type) { | |
| 495 | + setForm({ | |
| 496 | + typeCode: type.typeCode ?? "", | |
| 497 | + typeName: type.typeName ?? "", | |
| 498 | + state: type.state ?? true, | |
| 499 | + orderNum: type.orderNum ?? null, | |
| 500 | + }); | |
| 501 | + } | |
| 502 | + }, [open, type]); | |
| 503 | + | |
| 504 | + const submit = async () => { | |
| 505 | + if (!type?.id) return; | |
| 506 | + if (!form.typeCode.trim() || !form.typeName.trim()) { | |
| 507 | + toast.error("Validation failed", { | |
| 508 | + description: "Type Code and Type Name are required.", | |
| 509 | + }); | |
| 510 | + return; | |
| 511 | + } | |
| 512 | + | |
| 513 | + setSubmitting(true); | |
| 514 | + try { | |
| 515 | + await updateLabelType(type.id, form); | |
| 516 | + toast.success("Label type updated.", { | |
| 517 | + description: "The label type has been updated successfully.", | |
| 518 | + }); | |
| 519 | + onOpenChange(false); | |
| 520 | + onUpdated(); | |
| 521 | + } catch (e: any) { | |
| 522 | + toast.error("Failed to update label type.", { | |
| 523 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 524 | + }); | |
| 525 | + } finally { | |
| 526 | + setSubmitting(false); | |
| 527 | + } | |
| 528 | + }; | |
| 529 | + | |
| 530 | + return ( | |
| 531 | + <Dialog open={open} onOpenChange={onOpenChange}> | |
| 532 | + <DialogContent className="sm:max-w-[600px]"> | |
| 533 | + <DialogHeader> | |
| 534 | + <DialogTitle>Edit Label Type</DialogTitle> | |
| 535 | + <DialogDescription> | |
| 536 | + Update the label type details. | |
| 537 | + </DialogDescription> | |
| 538 | + </DialogHeader> | |
| 539 | + | |
| 540 | + <div className="grid gap-4 py-4"> | |
| 541 | + <div className="grid grid-cols-2 gap-4"> | |
| 542 | + <div className="space-y-2"> | |
| 543 | + <Label>Type Code *</Label> | |
| 544 | + <Input | |
| 545 | + placeholder="e.g. TYPE_DEFROST" | |
| 546 | + value={form.typeCode} | |
| 547 | + onChange={(e) => setForm((p) => ({ ...p, typeCode: e.target.value }))} | |
| 548 | + /> | |
| 549 | + </div> | |
| 550 | + <div className="space-y-2"> | |
| 551 | + <Label>Type Name *</Label> | |
| 552 | + <Input | |
| 553 | + placeholder="e.g. Defrost" | |
| 554 | + value={form.typeName} | |
| 555 | + onChange={(e) => setForm((p) => ({ ...p, typeName: e.target.value }))} | |
| 556 | + /> | |
| 557 | + </div> | |
| 558 | + </div> | |
| 559 | + | |
| 560 | + <div className="grid grid-cols-2 gap-4"> | |
| 561 | + <div className="space-y-2"> | |
| 562 | + <Label>Order</Label> | |
| 563 | + <Input | |
| 564 | + type="number" | |
| 565 | + placeholder="e.g. 1" | |
| 566 | + value={form.orderNum ?? ""} | |
| 567 | + onChange={(e) => setForm((p) => ({ ...p, orderNum: e.target.value ? Number(e.target.value) : null }))} | |
| 568 | + /> | |
| 569 | + </div> | |
| 570 | + <div className="flex items-center justify-between border border-gray-200 rounded-md px-3 bg-white" style={{ height: 40 }}> | |
| 571 | + <div className="text-sm font-medium text-gray-900">Enabled</div> | |
| 572 | + <Switch checked={form.state} onCheckedChange={(checked) => setForm((p) => ({ ...p, state: checked }))} /> | |
| 573 | + </div> | |
| 574 | + </div> | |
| 575 | + </div> | |
| 576 | + | |
| 577 | + <DialogFooter> | |
| 578 | + <Button variant="outline" onClick={() => onOpenChange(false)}> | |
| 579 | + Cancel | |
| 580 | + </Button> | |
| 581 | + <Button disabled={submitting} onClick={submit}> | |
| 582 | + {submitting ? "Updating..." : "Update"} | |
| 583 | + </Button> | |
| 584 | + </DialogFooter> | |
| 585 | + </DialogContent> | |
| 586 | + </Dialog> | |
| 587 | + ); | |
| 588 | +} | |
| 589 | + | |
| 590 | +function DeleteLabelTypeDialog({ | |
| 591 | + open, | |
| 592 | + type, | |
| 593 | + onOpenChange, | |
| 594 | + onDeleted, | |
| 595 | +}: { | |
| 596 | + open: boolean; | |
| 597 | + type: LabelTypeDto | null; | |
| 598 | + onOpenChange: (open: boolean) => void; | |
| 599 | + onDeleted: () => void; | |
| 600 | +}) { | |
| 601 | + const [submitting, setSubmitting] = useState(false); | |
| 602 | + | |
| 603 | + const name = useMemo(() => { | |
| 604 | + const n = (type?.typeName ?? "").trim(); | |
| 605 | + return n || type?.typeCode || "this type"; | |
| 606 | + }, [type]); | |
| 607 | + | |
| 608 | + const submit = async () => { | |
| 609 | + if (!type?.id) return; | |
| 610 | + setSubmitting(true); | |
| 611 | + try { | |
| 612 | + await deleteLabelType(type.id); | |
| 613 | + toast.success("Label type deleted.", { | |
| 614 | + description: "The label type has been removed successfully.", | |
| 615 | + }); | |
| 616 | + onOpenChange(false); | |
| 617 | + onDeleted(); | |
| 618 | + } catch (e: any) { | |
| 619 | + toast.error("Failed to delete label type.", { | |
| 620 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 621 | + }); | |
| 622 | + } finally { | |
| 623 | + setSubmitting(false); | |
| 624 | + } | |
| 625 | + }; | |
| 626 | + | |
| 627 | + return ( | |
| 628 | + <Dialog open={open} onOpenChange={onOpenChange}> | |
| 629 | + <DialogContent className="sm:max-w-none" style={{ width: "30%" }}> | |
| 630 | + <DialogHeader> | |
| 631 | + <DialogTitle>Delete Label Type</DialogTitle> | |
| 632 | + <DialogDescription>This action cannot be undone.</DialogDescription> | |
| 633 | + </DialogHeader> | |
| 634 | + | |
| 635 | + <div className="text-sm text-gray-700"> | |
| 636 | + Are you sure you want to delete <span className="font-medium">{name}</span>? | |
| 637 | + </div> | |
| 638 | + | |
| 639 | + <DialogFooter className="flex-row flex-wrap justify-end"> | |
| 640 | + <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}> | |
| 641 | + Cancel | |
| 642 | + </Button> | |
| 643 | + <Button | |
| 644 | + className="min-w-24" | |
| 645 | + variant="destructive" | |
| 646 | + disabled={submitting} | |
| 647 | + onClick={submit} | |
| 648 | + > | |
| 649 | + {submitting ? "Deleting..." : "Delete"} | |
| 650 | + </Button> | |
| 651 | + </DialogFooter> | |
| 652 | + </DialogContent> | |
| 653 | + </Dialog> | |
| 654 | + ); | |
| 655 | +} | ... | ... |
美国版/Food Labeling Management Platform/src/components/labels/LabelsList.tsx
| 1 | -import React from 'react'; | |
| 1 | +import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; | |
| 2 | 2 | import { |
| 3 | 3 | Table, |
| 4 | 4 | TableBody, |
| ... | ... | @@ -16,130 +16,1164 @@ import { |
| 16 | 16 | SelectTrigger, |
| 17 | 17 | SelectValue, |
| 18 | 18 | } from "../ui/select"; |
| 19 | -import { Plus } from "lucide-react"; | |
| 19 | +import { | |
| 20 | + Dialog, | |
| 21 | + DialogContent, | |
| 22 | + DialogDescription, | |
| 23 | + DialogFooter, | |
| 24 | + DialogHeader, | |
| 25 | + DialogTitle, | |
| 26 | +} from "../ui/dialog"; | |
| 27 | +import { Label } from "../ui/label"; | |
| 28 | +import { Switch } from "../ui/switch"; | |
| 29 | +import { Badge } from "../ui/badge"; | |
| 30 | +import { Plus, Edit, MoreHorizontal, ChevronsUpDown } from "lucide-react"; | |
| 31 | +import { toast } from "sonner"; | |
| 32 | +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | |
| 33 | +import { Checkbox } from "../ui/checkbox"; | |
| 34 | +import { SearchableSelect } from "../ui/searchable-select"; | |
| 35 | +import { | |
| 36 | + Command, | |
| 37 | + CommandEmpty, | |
| 38 | + CommandGroup, | |
| 39 | + CommandInput, | |
| 40 | + CommandItem, | |
| 41 | + CommandList, | |
| 42 | +} from "../ui/command"; | |
| 43 | +import { | |
| 44 | + Pagination, | |
| 45 | + PaginationContent, | |
| 46 | + PaginationItem, | |
| 47 | + PaginationLink, | |
| 48 | + PaginationNext, | |
| 49 | + PaginationPrevious, | |
| 50 | +} from "../ui/pagination"; | |
| 51 | +import { getLabels, getLabel, createLabel, updateLabel, deleteLabel } from "../../services/labelService"; | |
| 52 | +import type { LabelDto, LabelCreateInput, LabelUpdateInput } from "../../types/label"; | |
| 53 | +import { getLocations } from "../../services/locationService"; | |
| 54 | +import { getLabelCategories } from "../../services/labelCategoryService"; | |
| 55 | +import { getLabelTypes } from "../../services/labelTypeService"; | |
| 56 | +import { getLabelTemplates } from "../../services/labelTemplateService"; | |
| 57 | +import { getProducts } from "../../services/productService"; | |
| 58 | +import type { LocationDto } from "../../types/location"; | |
| 59 | +import type { LabelCategoryDto } from "../../types/labelCategory"; | |
| 60 | +import type { LabelTypeDto } from "../../types/labelType"; | |
| 61 | +import type { LabelTemplateDto } from "../../types/labelTemplate"; | |
| 62 | +import type { ProductDto } from "../../types/product"; | |
| 20 | 63 | |
| 21 | -export function LabelsList() { | |
| 22 | - const labels = [ | |
| 23 | - { | |
| 24 | - id: 1, | |
| 25 | - location: 'Location A', | |
| 26 | - labelCategory: 'Prep', | |
| 27 | - productCategory: 'Meat', | |
| 28 | - product: 'Chicken', | |
| 29 | - template: '2"x2" Basic', | |
| 30 | - labelType: 'Defrost', | |
| 31 | - lastEdited: '2025.12.03.11:45', | |
| 32 | - hasError: false | |
| 33 | - }, | |
| 34 | - { | |
| 35 | - id: 2, | |
| 36 | - location: 'Location A', | |
| 37 | - labelCategory: 'Prep', | |
| 38 | - productCategory: 'Meat', | |
| 39 | - product: 'Chicken', | |
| 40 | - template: '2"x2" Basic', | |
| 41 | - labelType: 'Opened/Preped', | |
| 42 | - lastEdited: '2025.12.03.11:45', | |
| 43 | - hasError: false | |
| 44 | - }, | |
| 45 | - { | |
| 46 | - id: 3, | |
| 47 | - location: 'Location A', | |
| 48 | - labelCategory: 'Prep', | |
| 49 | - productCategory: 'Meat', | |
| 50 | - product: 'Chicken', | |
| 51 | - template: '2"x2" Basic', | |
| 52 | - labelType: 'Heated', | |
| 53 | - lastEdited: '2025.12.03.11:45', | |
| 54 | - hasError: false | |
| 55 | - }, | |
| 56 | - { | |
| 57 | - id: 4, | |
| 58 | - location: 'Location A', | |
| 59 | - labelCategory: "Grab'n'Go", | |
| 60 | - productCategory: 'Sandwich', | |
| 61 | - product: 'Chicken Sandwich', | |
| 62 | - template: '2"x6" G\'n\'G', | |
| 63 | - labelType: '', | |
| 64 | - lastEdited: '2025.12.03.11:45', | |
| 65 | - hasError: true | |
| 64 | +function toDisplay(v: string | null | undefined): string { | |
| 65 | + const s = (v ?? "").trim(); | |
| 66 | + return s ? s : "None"; | |
| 67 | +} | |
| 68 | + | |
| 69 | +/** 列表行:标签编码(接口可能只返回 id 为 LabelCode) */ | |
| 70 | +function labelRowCode(item: LabelDto): string { | |
| 71 | + const c = (item.labelCode ?? item.id ?? "").trim(); | |
| 72 | + return c || "None"; | |
| 73 | +} | |
| 74 | + | |
| 75 | +/** 列表行:产品列(优先展示名称,否则展示绑定数量) */ | |
| 76 | +function labelRowProductsText(item: LabelDto): string { | |
| 77 | + const pn = (item.productName ?? "").trim(); | |
| 78 | + if (pn) return pn; | |
| 79 | + const n = item.productIds?.length ?? 0; | |
| 80 | + if (n > 0) return `${n} product(s)`; | |
| 81 | + return "None"; | |
| 82 | +} | |
| 83 | + | |
| 84 | +/** 列表行:最后编辑时间 */ | |
| 85 | +function labelRowLastEdited(item: LabelDto): string { | |
| 86 | + const le = (item.lastEdited ?? "").trim(); | |
| 87 | + if (le) return le; | |
| 88 | + const ct = item.creationTime; | |
| 89 | + if (ct) { | |
| 90 | + try { | |
| 91 | + return new Date(ct).toLocaleString(); | |
| 92 | + } catch { | |
| 93 | + return String(ct); | |
| 94 | + } | |
| 95 | + } | |
| 96 | + return "None"; | |
| 97 | +} | |
| 98 | + | |
| 99 | +/** 详情 / 列表行 → 编辑表单(列表接口可能缺 ID 字段,需再以 GET 详情补全) */ | |
| 100 | +function labelDtoToUpdateForm(d: LabelDto): LabelUpdateInput { | |
| 101 | + const ids = d.productIds; | |
| 102 | + return { | |
| 103 | + labelName: d.labelName ?? "", | |
| 104 | + templateCode: d.templateCode ?? "", | |
| 105 | + locationId: d.locationId ?? "", | |
| 106 | + labelCategoryId: d.labelCategoryId ?? "", | |
| 107 | + labelTypeId: d.labelTypeId ?? "", | |
| 108 | + productIds: Array.isArray(ids) ? [...ids] : [], | |
| 109 | + labelInfoJson: d.labelInfoJson ?? null, | |
| 110 | + state: d.state ?? true, | |
| 111 | + }; | |
| 112 | +} | |
| 113 | + | |
| 114 | +type ProductOptionRow = { id: string; name: string }; | |
| 115 | + | |
| 116 | +function templateListCode(t: LabelTemplateDto): string { | |
| 117 | + return (t.templateCode ?? t.id ?? "").trim(); | |
| 118 | +} | |
| 119 | + | |
| 120 | +function templateListLabel(t: LabelTemplateDto): string { | |
| 121 | + const name = (t.templateName ?? t.name ?? "").trim() || "None"; | |
| 122 | + const code = templateListCode(t) || "None"; | |
| 123 | + return `${name} (${code})`; | |
| 124 | +} | |
| 125 | + | |
| 126 | +function useLabelFormReferenceData(open: boolean) { | |
| 127 | + const [loading, setLoading] = useState(false); | |
| 128 | + const [templates, setTemplates] = useState<LabelTemplateDto[]>([]); | |
| 129 | + const [locations, setLocations] = useState<LocationDto[]>([]); | |
| 130 | + const [categories, setCategories] = useState<LabelCategoryDto[]>([]); | |
| 131 | + const [types, setTypes] = useState<LabelTypeDto[]>([]); | |
| 132 | + const [products, setProducts] = useState<ProductDto[]>([]); | |
| 133 | + | |
| 134 | + useEffect(() => { | |
| 135 | + if (!open) return; | |
| 136 | + let cancelled = false; | |
| 137 | + (async () => { | |
| 138 | + setLoading(true); | |
| 139 | + try { | |
| 140 | + const [tplRes, locRes, catRes, typeRes, prodRes] = await Promise.all([ | |
| 141 | + getLabelTemplates({ skipCount: 0, maxResultCount: 500 }), | |
| 142 | + getLocations({ skipCount: 0, maxResultCount: 500 }), | |
| 143 | + getLabelCategories({ skipCount: 0, maxResultCount: 500 }), | |
| 144 | + getLabelTypes({ skipCount: 0, maxResultCount: 500 }), | |
| 145 | + getProducts({ skipCount: 0, maxResultCount: 500 }), | |
| 146 | + ]); | |
| 147 | + if (cancelled) return; | |
| 148 | + setTemplates(tplRes.items ?? []); | |
| 149 | + setLocations(locRes.items ?? []); | |
| 150 | + setCategories(catRes.items ?? []); | |
| 151 | + setTypes(typeRes.items ?? []); | |
| 152 | + setProducts(prodRes.items ?? []); | |
| 153 | + } catch (e: any) { | |
| 154 | + if (!cancelled) { | |
| 155 | + toast.error("Failed to load options", { | |
| 156 | + description: e?.message ? String(e.message) : "Check network or sign-in.", | |
| 157 | + }); | |
| 158 | + setTemplates([]); | |
| 159 | + setLocations([]); | |
| 160 | + setCategories([]); | |
| 161 | + setTypes([]); | |
| 162 | + setProducts([]); | |
| 163 | + } | |
| 164 | + } finally { | |
| 165 | + if (!cancelled) setLoading(false); | |
| 166 | + } | |
| 167 | + })(); | |
| 168 | + return () => { | |
| 169 | + cancelled = true; | |
| 170 | + }; | |
| 171 | + }, [open]); | |
| 172 | + | |
| 173 | + const productOptions: ProductOptionRow[] = useMemo( | |
| 174 | + () => | |
| 175 | + products.map((p) => { | |
| 176 | + const name = | |
| 177 | + (p.productName ?? p.productCode ?? "").trim() || p.id; | |
| 178 | + return { id: p.id, name }; | |
| 179 | + }), | |
| 180 | + [products], | |
| 181 | + ); | |
| 182 | + | |
| 183 | + return { loading, templates, locations, categories, types, productOptions }; | |
| 184 | +} | |
| 185 | + | |
| 186 | +function ProductMultiSelectField({ | |
| 187 | + value, | |
| 188 | + onChange, | |
| 189 | + disabled, | |
| 190 | + productOptions, | |
| 191 | +}: { | |
| 192 | + value: string[]; | |
| 193 | + onChange: (next: string[]) => void; | |
| 194 | + disabled?: boolean; | |
| 195 | + productOptions: ProductOptionRow[]; | |
| 196 | +}) { | |
| 197 | + const [open, setOpen] = useState(false); | |
| 198 | + const summary = useMemo(() => { | |
| 199 | + if (value.length === 0) return "Select products (multi-select)"; | |
| 200 | + const names = value | |
| 201 | + .map((id) => productOptions.find((p) => p.id === id)?.name ?? id) | |
| 202 | + .slice(0, 2); | |
| 203 | + const more = value.length > 2 ? `, ${value.length} total` : ""; | |
| 204 | + return `${names.join(", ")}${more}`; | |
| 205 | + }, [value, productOptions]); | |
| 206 | + | |
| 207 | + const toggle = useCallback( | |
| 208 | + (id: string, checked: boolean) => { | |
| 209 | + const set = new Set(value); | |
| 210 | + if (checked) set.add(id); | |
| 211 | + else set.delete(id); | |
| 212 | + onChange(Array.from(set)); | |
| 66 | 213 | }, |
| 67 | - ]; | |
| 214 | + [value, onChange], | |
| 215 | + ); | |
| 216 | + | |
| 217 | + const extraProductIds = useMemo( | |
| 218 | + () => value.filter((id) => !productOptions.some((p) => p.id === id)), | |
| 219 | + [value, productOptions], | |
| 220 | + ); | |
| 68 | 221 | |
| 69 | 222 | return ( |
| 70 | - <div className="space-y-6"> | |
| 71 | - {/* Toolbar: Search, All Locations, Bulk group (single rounded box), New Label */} | |
| 72 | - <div className="flex flex-nowrap items-center gap-3"> | |
| 73 | - <Input | |
| 74 | - placeholder="Search" | |
| 75 | - style={{ height: 40, boxSizing: 'border-box' }} | |
| 76 | - className="bg-white border border-gray-300 rounded-md w-40 shrink-0 text-gray-900 placeholder:text-gray-500" | |
| 77 | - /> | |
| 78 | - <Select defaultValue="all"> | |
| 79 | - <SelectTrigger className="bg-white border border-gray-300 rounded-md w-[200px] shrink-0 text-gray-900" style={{ height: 40, boxSizing: 'border-box' }}> | |
| 80 | - <SelectValue placeholder="Location" /> | |
| 81 | - </SelectTrigger> | |
| 82 | - <SelectContent> | |
| 83 | - <SelectItem value="all">All Locations</SelectItem> | |
| 84 | - <SelectItem value="loc-a">Location A</SelectItem> | |
| 85 | - <SelectItem value="loc-b">Location B</SelectItem> | |
| 86 | - </SelectContent> | |
| 87 | - </Select> | |
| 88 | - <div className="flex rounded-md border border-gray-300 bg-white h-10 overflow-hidden shrink-0"> | |
| 89 | - <button type="button" className="px-4 h-full border-r border-gray-200 text-sm font-medium text-gray-900 hover:bg-gray-50 transition-colors"> | |
| 90 | - Bulk Import | |
| 91 | - </button> | |
| 92 | - <button type="button" className="px-4 h-full border-r border-gray-200 text-sm font-medium text-gray-900 hover:bg-gray-50 transition-colors"> | |
| 93 | - Bulk Export | |
| 94 | - </button> | |
| 95 | - <button type="button" className="px-4 h-full text-sm font-medium text-gray-900 hover:bg-gray-50 transition-colors"> | |
| 96 | - Bulk Edit | |
| 97 | - </button> | |
| 98 | - </div> | |
| 99 | - <Button className="bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md h-10 px-6 shrink-0 ml-auto"> | |
| 100 | - New Label <Plus className="ml-1 h-4 w-4" /> | |
| 223 | + <Popover open={open} onOpenChange={setOpen}> | |
| 224 | + <PopoverTrigger asChild> | |
| 225 | + <Button | |
| 226 | + type="button" | |
| 227 | + variant="outline" | |
| 228 | + role="combobox" | |
| 229 | + disabled={disabled} | |
| 230 | + className="w-full justify-between h-10 px-3 font-normal border border-gray-300 bg-white" | |
| 231 | + > | |
| 232 | + <span className="truncate text-left text-sm">{summary}</span> | |
| 233 | + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> | |
| 101 | 234 | </Button> |
| 102 | - </div> | |
| 235 | + </PopoverTrigger> | |
| 236 | + <PopoverContent | |
| 237 | + className="w-[var(--radix-popover-trigger-width)] max-w-[min(100vw-2rem,400px)] p-0" | |
| 238 | + align="start" | |
| 239 | + > | |
| 240 | + <Command> | |
| 241 | + <CommandInput placeholder="Search products…" /> | |
| 242 | + <CommandList> | |
| 243 | + <CommandEmpty>No matching products.</CommandEmpty> | |
| 244 | + <CommandGroup> | |
| 245 | + {productOptions.map((p) => ( | |
| 246 | + <CommandItem | |
| 247 | + key={p.id} | |
| 248 | + value={`${p.name} ${p.id}`} | |
| 249 | + onSelect={() => { | |
| 250 | + toggle(p.id, !value.includes(p.id)); | |
| 251 | + }} | |
| 252 | + className="cursor-pointer" | |
| 253 | + > | |
| 254 | + <Checkbox | |
| 255 | + className="pointer-events-none" | |
| 256 | + checked={value.includes(p.id)} | |
| 257 | + /> | |
| 258 | + <span className="flex-1 min-w-0"> | |
| 259 | + <span className="font-medium">{p.name}</span> | |
| 260 | + <span className="block text-xs text-gray-400 truncate">{p.id}</span> | |
| 261 | + </span> | |
| 262 | + </CommandItem> | |
| 263 | + ))} | |
| 264 | + {extraProductIds.length > 0 ? ( | |
| 265 | + <CommandGroup heading="Linked (not in current list, can deselect)"> | |
| 266 | + {extraProductIds.map((id) => ( | |
| 267 | + <CommandItem | |
| 268 | + key={id} | |
| 269 | + value={id} | |
| 270 | + onSelect={() => { | |
| 271 | + toggle(id, !value.includes(id)); | |
| 272 | + }} | |
| 273 | + className="cursor-pointer" | |
| 274 | + > | |
| 275 | + <Checkbox className="pointer-events-none" checked={value.includes(id)} /> | |
| 276 | + <span className="text-xs font-mono truncate">{id}</span> | |
| 277 | + </CommandItem> | |
| 278 | + ))} | |
| 279 | + </CommandGroup> | |
| 280 | + ) : null} | |
| 281 | + </CommandGroup> | |
| 282 | + </CommandList> | |
| 283 | + </Command> | |
| 284 | + </PopoverContent> | |
| 285 | + </Popover> | |
| 286 | + ); | |
| 287 | +} | |
| 288 | + | |
| 289 | +export function LabelsList() { | |
| 290 | + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); | |
| 291 | + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); | |
| 292 | + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); | |
| 293 | + const [editingLabel, setEditingLabel] = useState<LabelDto | null>(null); | |
| 294 | + const [deletingLabel, setDeletingLabel] = useState<LabelDto | null>(null); | |
| 295 | + const [labels, setLabels] = useState<LabelDto[]>([]); | |
| 296 | + const [loading, setLoading] = useState(false); | |
| 297 | + const [total, setTotal] = useState(0); | |
| 298 | + const [refreshSeq, setRefreshSeq] = useState(0); | |
| 299 | + const [actionsOpenForId, setActionsOpenForId] = useState<string | null>(null); | |
| 300 | + | |
| 301 | + const [keyword, setKeyword] = useState(""); | |
| 302 | + const [locationFilter, setLocationFilter] = useState<string>("all"); | |
| 303 | + const [labelCategoryFilter, setLabelCategoryFilter] = useState<string>("all"); | |
| 304 | + const [labelTypeFilter, setLabelTypeFilter] = useState<string>("all"); | |
| 305 | + const [templateFilter, setTemplateFilter] = useState<string>("all"); | |
| 306 | + const [stateFilter, setStateFilter] = useState<string>("all"); | |
| 307 | + | |
| 308 | + const [pageIndex, setPageIndex] = useState(1); | |
| 309 | + const [pageSize, setPageSize] = useState(10); | |
| 310 | + | |
| 311 | + const abortRef = useRef<AbortController | null>(null); | |
| 312 | + const keywordTimerRef = useRef<number | null>(null); | |
| 313 | + const [debouncedKeyword, setDebouncedKeyword] = useState(""); | |
| 314 | + | |
| 315 | + useEffect(() => { | |
| 316 | + if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); | |
| 317 | + keywordTimerRef.current = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300); | |
| 318 | + return () => { | |
| 319 | + if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); | |
| 320 | + }; | |
| 321 | + }, [keyword]); | |
| 322 | + | |
| 323 | + const totalPages = Math.max(1, Math.ceil(total / pageSize)); | |
| 324 | + | |
| 325 | + useEffect(() => { | |
| 326 | + setPageIndex(1); | |
| 327 | + }, [debouncedKeyword, locationFilter, labelCategoryFilter, labelTypeFilter, templateFilter, stateFilter, pageSize]); | |
| 103 | 328 | |
| 104 | - {/* Warning Text */} | |
| 105 | - <div className="text-red-600 font-bold italic text-lg"> | |
| 106 | - One or more of your labels are missing fields from their templates (! ! ! 1 in total). | |
| 329 | + useEffect(() => { | |
| 330 | + const run = async () => { | |
| 331 | + abortRef.current?.abort(); | |
| 332 | + const ac = new AbortController(); | |
| 333 | + abortRef.current = ac; | |
| 334 | + | |
| 335 | + setLoading(true); | |
| 336 | + try { | |
| 337 | + const skipCount = (pageIndex - 1) * pageSize; | |
| 338 | + const res = await getLabels( | |
| 339 | + { | |
| 340 | + skipCount, | |
| 341 | + maxResultCount: pageSize, | |
| 342 | + keyword: debouncedKeyword || undefined, | |
| 343 | + locationId: locationFilter !== "all" ? locationFilter : undefined, | |
| 344 | + labelCategoryId: labelCategoryFilter !== "all" ? labelCategoryFilter : undefined, | |
| 345 | + labelTypeId: labelTypeFilter !== "all" ? labelTypeFilter : undefined, | |
| 346 | + templateCode: templateFilter !== "all" ? templateFilter : undefined, | |
| 347 | + state: stateFilter === "all" ? undefined : stateFilter === "true", | |
| 348 | + }, | |
| 349 | + ac.signal, | |
| 350 | + ); | |
| 351 | + | |
| 352 | + setLabels(res.items ?? []); | |
| 353 | + setTotal(res.totalCount ?? 0); | |
| 354 | + } catch (e: any) { | |
| 355 | + if (e?.name === "AbortError") return; | |
| 356 | + toast.error("Failed to load labels.", { | |
| 357 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 358 | + }); | |
| 359 | + setLabels([]); | |
| 360 | + setTotal(0); | |
| 361 | + } finally { | |
| 362 | + setLoading(false); | |
| 363 | + } | |
| 364 | + }; | |
| 365 | + | |
| 366 | + run(); | |
| 367 | + return () => abortRef.current?.abort(); | |
| 368 | + }, [debouncedKeyword, locationFilter, labelCategoryFilter, labelTypeFilter, templateFilter, stateFilter, pageIndex, pageSize, refreshSeq]); | |
| 369 | + | |
| 370 | + const refreshList = () => setRefreshSeq((x) => x + 1); | |
| 371 | + | |
| 372 | + const openEdit = (label: LabelDto) => { | |
| 373 | + setActionsOpenForId(null); | |
| 374 | + setEditingLabel(label); | |
| 375 | + setIsEditDialogOpen(true); | |
| 376 | + }; | |
| 377 | + | |
| 378 | + const openDelete = (label: LabelDto) => { | |
| 379 | + setActionsOpenForId(null); | |
| 380 | + setDeletingLabel(label); | |
| 381 | + setIsDeleteDialogOpen(true); | |
| 382 | + }; | |
| 383 | + | |
| 384 | + return ( | |
| 385 | + <div className="h-full flex flex-col"> | |
| 386 | + <div className="pb-4"> | |
| 387 | + <div className="flex flex-col gap-4"> | |
| 388 | + <div className="flex flex-nowrap items-center gap-3"> | |
| 389 | + <Input | |
| 390 | + placeholder="Search" | |
| 391 | + value={keyword} | |
| 392 | + onChange={(e) => setKeyword(e.target.value)} | |
| 393 | + style={{ height: 40, boxSizing: 'border-box' }} | |
| 394 | + className="bg-white border border-gray-300 rounded-md w-40 shrink-0 placeholder:text-gray-500" | |
| 395 | + /> | |
| 396 | + <Select value={locationFilter} onValueChange={setLocationFilter}> | |
| 397 | + <SelectTrigger className="bg-white border border-gray-300 rounded-md w-[150px] shrink-0" style={{ height: 40, boxSizing: 'border-box' }}> | |
| 398 | + <SelectValue placeholder="Location" /> | |
| 399 | + </SelectTrigger> | |
| 400 | + <SelectContent> | |
| 401 | + <SelectItem value="all">All Locations</SelectItem> | |
| 402 | + </SelectContent> | |
| 403 | + </Select> | |
| 404 | + <Select value={labelCategoryFilter} onValueChange={setLabelCategoryFilter}> | |
| 405 | + <SelectTrigger className="bg-white border border-gray-300 rounded-md w-[150px] shrink-0" style={{ height: 40, boxSizing: 'border-box' }}> | |
| 406 | + <SelectValue placeholder="Category" /> | |
| 407 | + </SelectTrigger> | |
| 408 | + <SelectContent> | |
| 409 | + <SelectItem value="all">All Categories</SelectItem> | |
| 410 | + </SelectContent> | |
| 411 | + </Select> | |
| 412 | + <Select value={labelTypeFilter} onValueChange={setLabelTypeFilter}> | |
| 413 | + <SelectTrigger className="bg-white border border-gray-300 rounded-md w-[150px] shrink-0" style={{ height: 40, boxSizing: 'border-box' }}> | |
| 414 | + <SelectValue placeholder="Type" /> | |
| 415 | + </SelectTrigger> | |
| 416 | + <SelectContent> | |
| 417 | + <SelectItem value="all">All Types</SelectItem> | |
| 418 | + </SelectContent> | |
| 419 | + </Select> | |
| 420 | + <Select value={stateFilter} onValueChange={setStateFilter}> | |
| 421 | + <SelectTrigger className="bg-white border border-gray-300 rounded-md w-[150px] shrink-0" style={{ height: 40, boxSizing: 'border-box' }}> | |
| 422 | + <SelectValue placeholder="State" /> | |
| 423 | + </SelectTrigger> | |
| 424 | + <SelectContent> | |
| 425 | + <SelectItem value="all">All States</SelectItem> | |
| 426 | + <SelectItem value="true">Active</SelectItem> | |
| 427 | + <SelectItem value="false">Inactive</SelectItem> | |
| 428 | + </SelectContent> | |
| 429 | + </Select> | |
| 430 | + <div className="flex-1" /> | |
| 431 | + <Button | |
| 432 | + className="bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md h-10 px-6 shrink-0" | |
| 433 | + onClick={() => setIsCreateDialogOpen(true)} | |
| 434 | + > | |
| 435 | + New Label <Plus className="ml-1 h-4 w-4" /> | |
| 436 | + </Button> | |
| 437 | + </div> | |
| 438 | + </div> | |
| 107 | 439 | </div> |
| 108 | 440 | |
| 109 | - {/* Table */} | |
| 110 | - <div className="rounded-md border bg-white shadow-sm"> | |
| 111 | - <Table> | |
| 112 | - <TableHeader> | |
| 113 | - <TableRow className="bg-gray-50 hover:bg-gray-50"> | |
| 114 | - <TableHead className="font-bold text-gray-900 w-[120px]">Location</TableHead> | |
| 115 | - <TableHead className="font-bold text-gray-900 w-[140px]">Label Category</TableHead> | |
| 116 | - <TableHead className="font-bold text-gray-900 w-[140px]">Product Category</TableHead> | |
| 117 | - <TableHead className="font-bold text-gray-900">Product</TableHead> | |
| 118 | - <TableHead className="font-bold text-gray-900">Template</TableHead> | |
| 119 | - <TableHead className="font-bold text-gray-900">Label Type</TableHead> | |
| 120 | - <TableHead className="font-bold text-gray-900">Last Edited</TableHead> | |
| 121 | - </TableRow> | |
| 122 | - </TableHeader> | |
| 123 | - <TableBody> | |
| 124 | - {labels.map((label) => ( | |
| 125 | - <TableRow key={label.id} className="hover:bg-gray-50"> | |
| 126 | - <TableCell className="font-medium">{label.location}</TableCell> | |
| 127 | - <TableCell>{label.labelCategory}</TableCell> | |
| 128 | - <TableCell>{label.productCategory}</TableCell> | |
| 129 | - <TableCell>{label.product}</TableCell> | |
| 130 | - <TableCell className="font-medium"> | |
| 131 | - {label.template} | |
| 132 | - {label.hasError && ( | |
| 133 | - <span className="text-red-600 font-bold ml-2">! ! !</span> | |
| 134 | - )} | |
| 135 | - </TableCell> | |
| 136 | - <TableCell>{label.labelType || '-'}</TableCell> | |
| 137 | - <TableCell className="text-gray-500 tabular-nums font-numeric">{label.lastEdited}</TableCell> | |
| 441 | + <div className="flex-1 overflow-auto pt-6"> | |
| 442 | + <div className="rounded-md border bg-white shadow-sm"> | |
| 443 | + <Table> | |
| 444 | + <TableHeader> | |
| 445 | + <TableRow className="bg-gray-50 hover:bg-gray-50"> | |
| 446 | + <TableHead className="font-bold text-gray-900 w-[120px]">Label Code</TableHead> | |
| 447 | + <TableHead className="font-bold text-gray-900 w-[140px]">Label Name</TableHead> | |
| 448 | + <TableHead className="font-bold text-gray-900 w-[120px]">Location</TableHead> | |
| 449 | + <TableHead className="font-bold text-gray-900 w-[140px]">Category</TableHead> | |
| 450 | + <TableHead className="font-bold text-gray-900 w-[140px]">Type</TableHead> | |
| 451 | + <TableHead className="font-bold text-gray-900 w-[120px]">Template</TableHead> | |
| 452 | + <TableHead className="font-bold text-gray-900">Products</TableHead> | |
| 453 | + <TableHead className="font-bold text-gray-900 w-[100px]">State</TableHead> | |
| 454 | + <TableHead className="font-bold text-gray-900">Last Edited</TableHead> | |
| 455 | + <TableHead className="font-bold text-gray-900 text-center w-[100px]">Actions</TableHead> | |
| 138 | 456 | </TableRow> |
| 139 | - ))} | |
| 140 | - </TableBody> | |
| 141 | - </Table> | |
| 457 | + </TableHeader> | |
| 458 | + <TableBody> | |
| 459 | + {loading ? ( | |
| 460 | + <TableRow> | |
| 461 | + <TableCell colSpan={10} className="text-center text-sm text-gray-500 py-10"> | |
| 462 | + Loading... | |
| 463 | + </TableCell> | |
| 464 | + </TableRow> | |
| 465 | + ) : labels.length === 0 ? ( | |
| 466 | + <TableRow> | |
| 467 | + <TableCell colSpan={10} className="text-center text-sm text-gray-500 py-10"> | |
| 468 | + No results. | |
| 469 | + </TableCell> | |
| 470 | + </TableRow> | |
| 471 | + ) : ( | |
| 472 | + labels.map((item) => ( | |
| 473 | + <TableRow key={item.id} className="hover:bg-gray-50"> | |
| 474 | + <TableCell className="font-medium whitespace-nowrap">{labelRowCode(item)}</TableCell> | |
| 475 | + <TableCell className="whitespace-nowrap">{toDisplay(item.labelName)}</TableCell> | |
| 476 | + <TableCell className="text-gray-600 whitespace-nowrap"> | |
| 477 | + {toDisplay(item.locationName ?? item.locationId)} | |
| 478 | + </TableCell> | |
| 479 | + <TableCell className="text-gray-600 whitespace-nowrap"> | |
| 480 | + {toDisplay(item.labelCategoryName ?? item.labelCategoryId)} | |
| 481 | + </TableCell> | |
| 482 | + <TableCell className="text-gray-600 whitespace-nowrap"> | |
| 483 | + {toDisplay(item.labelTypeName ?? item.labelTypeId)} | |
| 484 | + </TableCell> | |
| 485 | + <TableCell className="text-gray-600 whitespace-nowrap"> | |
| 486 | + {toDisplay(item.templateName ?? item.templateCode)} | |
| 487 | + </TableCell> | |
| 488 | + <TableCell className="text-gray-600 whitespace-nowrap">{labelRowProductsText(item)}</TableCell> | |
| 489 | + <TableCell> | |
| 490 | + <Badge className={item.state === true ? "bg-green-600" : "bg-gray-400"}> | |
| 491 | + {item.state === true ? "Active" : "Inactive"} | |
| 492 | + </Badge> | |
| 493 | + </TableCell> | |
| 494 | + <TableCell className="text-gray-500 tabular-nums font-numeric whitespace-nowrap"> | |
| 495 | + {labelRowLastEdited(item)} | |
| 496 | + </TableCell> | |
| 497 | + <TableCell className="text-center"> | |
| 498 | + <Popover | |
| 499 | + open={actionsOpenForId === item.id} | |
| 500 | + onOpenChange={(open) => setActionsOpenForId(open ? item.id : null)} | |
| 501 | + > | |
| 502 | + <PopoverTrigger asChild> | |
| 503 | + <Button | |
| 504 | + type="button" | |
| 505 | + variant="ghost" | |
| 506 | + size="icon" | |
| 507 | + className="h-8 w-8" | |
| 508 | + aria-label="Row actions" | |
| 509 | + > | |
| 510 | + <MoreHorizontal className="h-4 w-4 text-gray-500" /> | |
| 511 | + </Button> | |
| 512 | + </PopoverTrigger> | |
| 513 | + <PopoverContent align="end" className="w-40 p-1"> | |
| 514 | + <Button | |
| 515 | + type="button" | |
| 516 | + variant="ghost" | |
| 517 | + className="w-full justify-start gap-2 h-9 px-2 font-normal" | |
| 518 | + onClick={() => openEdit(item)} | |
| 519 | + > | |
| 520 | + <Edit className="w-4 h-4" /> | |
| 521 | + Edit | |
| 522 | + </Button> | |
| 523 | + <Button | |
| 524 | + type="button" | |
| 525 | + variant="ghost" | |
| 526 | + className="w-full justify-start h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50" | |
| 527 | + onClick={() => openDelete(item)} | |
| 528 | + > | |
| 529 | + Delete | |
| 530 | + </Button> | |
| 531 | + </PopoverContent> | |
| 532 | + </Popover> | |
| 533 | + </TableCell> | |
| 534 | + </TableRow> | |
| 535 | + )) | |
| 536 | + )} | |
| 537 | + </TableBody> | |
| 538 | + </Table> | |
| 539 | + </div> | |
| 142 | 540 | </div> |
| 541 | + | |
| 542 | + <div className="pt-4"> | |
| 543 | + <div className="flex items-center justify-between text-sm text-gray-600"> | |
| 544 | + <div> | |
| 545 | + Showing {total === 0 ? 0 : (pageIndex - 1) * pageSize + 1}- | |
| 546 | + {Math.min(pageIndex * pageSize, total)} of {total} | |
| 547 | + </div> | |
| 548 | + <div className="flex items-center gap-3"> | |
| 549 | + <Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}> | |
| 550 | + <SelectTrigger className="w-[110px] h-9 rounded-md border border-gray-300 bg-white text-gray-900"> | |
| 551 | + <SelectValue /> | |
| 552 | + </SelectTrigger> | |
| 553 | + <SelectContent> | |
| 554 | + {[10, 20, 50].map((n) => ( | |
| 555 | + <SelectItem key={n} value={String(n)}> | |
| 556 | + {n} / page | |
| 557 | + </SelectItem> | |
| 558 | + ))} | |
| 559 | + </SelectContent> | |
| 560 | + </Select> | |
| 561 | + <Pagination className="mx-0 w-auto justify-end"> | |
| 562 | + <PaginationContent> | |
| 563 | + <PaginationItem> | |
| 564 | + <PaginationPrevious | |
| 565 | + href="#" | |
| 566 | + size="default" | |
| 567 | + onClick={(e) => { | |
| 568 | + e.preventDefault(); | |
| 569 | + setPageIndex((p) => Math.max(1, p - 1)); | |
| 570 | + }} | |
| 571 | + aria-disabled={pageIndex <= 1} | |
| 572 | + className={pageIndex <= 1 ? "pointer-events-none opacity-50" : ""} | |
| 573 | + /> | |
| 574 | + </PaginationItem> | |
| 575 | + <PaginationItem> | |
| 576 | + <PaginationLink | |
| 577 | + href="#" | |
| 578 | + isActive | |
| 579 | + size="default" | |
| 580 | + onClick={(e) => e.preventDefault()} | |
| 581 | + > | |
| 582 | + Page {pageIndex} / {totalPages} | |
| 583 | + </PaginationLink> | |
| 584 | + </PaginationItem> | |
| 585 | + <PaginationItem> | |
| 586 | + <PaginationNext | |
| 587 | + href="#" | |
| 588 | + size="default" | |
| 589 | + onClick={(e) => { | |
| 590 | + e.preventDefault(); | |
| 591 | + setPageIndex((p) => Math.min(totalPages, p + 1)); | |
| 592 | + }} | |
| 593 | + aria-disabled={pageIndex >= totalPages} | |
| 594 | + className={pageIndex >= totalPages ? "pointer-events-none opacity-50" : ""} | |
| 595 | + /> | |
| 596 | + </PaginationItem> | |
| 597 | + </PaginationContent> | |
| 598 | + </Pagination> | |
| 599 | + </div> | |
| 600 | + </div> | |
| 601 | + </div> | |
| 602 | + | |
| 603 | + <CreateLabelDialog | |
| 604 | + open={isCreateDialogOpen} | |
| 605 | + onOpenChange={setIsCreateDialogOpen} | |
| 606 | + onCreated={() => { | |
| 607 | + setPageIndex(1); | |
| 608 | + refreshList(); | |
| 609 | + }} | |
| 610 | + /> | |
| 611 | + | |
| 612 | + <EditLabelDialog | |
| 613 | + open={isEditDialogOpen} | |
| 614 | + label={editingLabel} | |
| 615 | + onOpenChange={(open) => { | |
| 616 | + setIsEditDialogOpen(open); | |
| 617 | + if (!open) setEditingLabel(null); | |
| 618 | + }} | |
| 619 | + onUpdated={refreshList} | |
| 620 | + /> | |
| 621 | + | |
| 622 | + <DeleteLabelDialog | |
| 623 | + open={isDeleteDialogOpen} | |
| 624 | + label={deletingLabel} | |
| 625 | + onOpenChange={(open) => { | |
| 626 | + setIsDeleteDialogOpen(open); | |
| 627 | + if (!open) setDeletingLabel(null); | |
| 628 | + }} | |
| 629 | + onDeleted={refreshList} | |
| 630 | + /> | |
| 143 | 631 | </div> |
| 144 | 632 | ); |
| 145 | 633 | } |
| 634 | + | |
| 635 | +function CreateLabelDialog({ | |
| 636 | + open, | |
| 637 | + onOpenChange, | |
| 638 | + onCreated, | |
| 639 | +}: { | |
| 640 | + open: boolean; | |
| 641 | + onOpenChange: (open: boolean) => void; | |
| 642 | + onCreated: () => void; | |
| 643 | +}) { | |
| 644 | + const { loading: refLoading, templates, locations, categories, types, productOptions } = | |
| 645 | + useLabelFormReferenceData(open); | |
| 646 | + const [submitting, setSubmitting] = useState(false); | |
| 647 | + const [form, setForm] = useState<LabelCreateInput>({ | |
| 648 | + labelCode: "", | |
| 649 | + labelName: "", | |
| 650 | + templateCode: "", | |
| 651 | + locationId: "", | |
| 652 | + labelCategoryId: "", | |
| 653 | + labelTypeId: "", | |
| 654 | + productIds: [], | |
| 655 | + labelInfoJson: null, | |
| 656 | + state: true, | |
| 657 | + }); | |
| 658 | + | |
| 659 | + const resetForm = () => { | |
| 660 | + setForm({ | |
| 661 | + labelCode: "", | |
| 662 | + labelName: "", | |
| 663 | + templateCode: "", | |
| 664 | + locationId: "", | |
| 665 | + labelCategoryId: "", | |
| 666 | + labelTypeId: "", | |
| 667 | + productIds: [], | |
| 668 | + labelInfoJson: null, | |
| 669 | + state: true, | |
| 670 | + }); | |
| 671 | + }; | |
| 672 | + | |
| 673 | + useEffect(() => { | |
| 674 | + if (!open) { | |
| 675 | + resetForm(); | |
| 676 | + } | |
| 677 | + }, [open]); | |
| 678 | + | |
| 679 | + const submit = async () => { | |
| 680 | + if (!form.labelCode.trim() || !form.labelName.trim() || !form.templateCode.trim() || !form.locationId.trim() || !form.labelCategoryId.trim() || !form.labelTypeId.trim()) { | |
| 681 | + toast.error("Validation failed", { | |
| 682 | + description: "Fill all required fields and select template, location, category, and type.", | |
| 683 | + }); | |
| 684 | + return; | |
| 685 | + } | |
| 686 | + if (form.productIds.length === 0) { | |
| 687 | + toast.error("Validation failed", { | |
| 688 | + description: "Select at least one product.", | |
| 689 | + }); | |
| 690 | + return; | |
| 691 | + } | |
| 692 | + | |
| 693 | + setSubmitting(true); | |
| 694 | + try { | |
| 695 | + await createLabel(form); | |
| 696 | + toast.success("Label created.", { | |
| 697 | + description: "The label has been created successfully.", | |
| 698 | + }); | |
| 699 | + onOpenChange(false); | |
| 700 | + onCreated(); | |
| 701 | + } catch (e: any) { | |
| 702 | + toast.error("Failed to create label.", { | |
| 703 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 704 | + }); | |
| 705 | + } finally { | |
| 706 | + setSubmitting(false); | |
| 707 | + } | |
| 708 | + }; | |
| 709 | + | |
| 710 | + const templateOptions = useMemo( | |
| 711 | + () => | |
| 712 | + templates | |
| 713 | + .filter((t) => templateListCode(t)) | |
| 714 | + .map((t) => ({ | |
| 715 | + value: templateListCode(t), | |
| 716 | + label: templateListLabel(t), | |
| 717 | + })), | |
| 718 | + [templates], | |
| 719 | + ); | |
| 720 | + | |
| 721 | + const locationOptions = useMemo( | |
| 722 | + () => | |
| 723 | + locations.map((loc) => ({ | |
| 724 | + value: loc.id, | |
| 725 | + label: toDisplay(loc.locationName ?? loc.locationCode ?? loc.id), | |
| 726 | + })), | |
| 727 | + [locations], | |
| 728 | + ); | |
| 729 | + | |
| 730 | + const categoryOptions = useMemo( | |
| 731 | + () => | |
| 732 | + categories.map((c) => ({ | |
| 733 | + value: c.id, | |
| 734 | + label: toDisplay(c.categoryName ?? c.categoryCode ?? c.id), | |
| 735 | + })), | |
| 736 | + [categories], | |
| 737 | + ); | |
| 738 | + | |
| 739 | + const typeOptions = useMemo( | |
| 740 | + () => | |
| 741 | + types.map((ty) => ({ | |
| 742 | + value: ty.id, | |
| 743 | + label: toDisplay(ty.typeName ?? ty.typeCode ?? ty.id), | |
| 744 | + })), | |
| 745 | + [types], | |
| 746 | + ); | |
| 747 | + | |
| 748 | + return ( | |
| 749 | + <Dialog open={open} onOpenChange={onOpenChange}> | |
| 750 | + <DialogContent className="sm:max-w-[600px]"> | |
| 751 | + <DialogHeader> | |
| 752 | + <DialogTitle>Add New Label</DialogTitle> | |
| 753 | + <DialogDescription>Enter the details for the new label.</DialogDescription> | |
| 754 | + </DialogHeader> | |
| 755 | + | |
| 756 | + <div className="grid gap-4 py-4"> | |
| 757 | + <div className="grid grid-cols-2 gap-4"> | |
| 758 | + <div className="space-y-2"> | |
| 759 | + <Label>Label Code *</Label> | |
| 760 | + <Input | |
| 761 | + className="h-10" | |
| 762 | + placeholder="e.g. LBL_TEST_001" | |
| 763 | + value={form.labelCode} | |
| 764 | + onChange={(e) => setForm((p) => ({ ...p, labelCode: e.target.value }))} | |
| 765 | + /> | |
| 766 | + </div> | |
| 767 | + <div className="space-y-2"> | |
| 768 | + <Label>Label Name *</Label> | |
| 769 | + <Input | |
| 770 | + className="h-10" | |
| 771 | + placeholder="e.g. Breakfast label" | |
| 772 | + value={form.labelName} | |
| 773 | + onChange={(e) => setForm((p) => ({ ...p, labelName: e.target.value }))} | |
| 774 | + /> | |
| 775 | + </div> | |
| 776 | + </div> | |
| 777 | + | |
| 778 | + <div className="grid grid-cols-2 gap-4"> | |
| 779 | + <div className="space-y-2"> | |
| 780 | + <Label>Label Template *</Label> | |
| 781 | + <SearchableSelect | |
| 782 | + value={form.templateCode} | |
| 783 | + onValueChange={(v) => setForm((p) => ({ ...p, templateCode: v }))} | |
| 784 | + options={templateOptions} | |
| 785 | + placeholder="Select template" | |
| 786 | + searchPlaceholder="Search template…" | |
| 787 | + emptyText="No templates found." | |
| 788 | + disabled={refLoading} | |
| 789 | + /> | |
| 790 | + </div> | |
| 791 | + <div className="space-y-2"> | |
| 792 | + <Label>Location *</Label> | |
| 793 | + <SearchableSelect | |
| 794 | + value={form.locationId} | |
| 795 | + onValueChange={(v) => setForm((p) => ({ ...p, locationId: v }))} | |
| 796 | + options={locationOptions} | |
| 797 | + placeholder="Select location" | |
| 798 | + searchPlaceholder="Search location…" | |
| 799 | + emptyText="No locations found." | |
| 800 | + disabled={refLoading} | |
| 801 | + /> | |
| 802 | + </div> | |
| 803 | + </div> | |
| 804 | + | |
| 805 | + <div className="grid grid-cols-2 gap-4"> | |
| 806 | + <div className="space-y-2"> | |
| 807 | + <Label>Label Category *</Label> | |
| 808 | + <SearchableSelect | |
| 809 | + value={form.labelCategoryId} | |
| 810 | + onValueChange={(v) => setForm((p) => ({ ...p, labelCategoryId: v }))} | |
| 811 | + options={categoryOptions} | |
| 812 | + placeholder="Select category" | |
| 813 | + searchPlaceholder="Search category…" | |
| 814 | + emptyText="No categories found." | |
| 815 | + disabled={refLoading} | |
| 816 | + /> | |
| 817 | + </div> | |
| 818 | + <div className="space-y-2"> | |
| 819 | + <Label>Label Type *</Label> | |
| 820 | + <SearchableSelect | |
| 821 | + value={form.labelTypeId} | |
| 822 | + onValueChange={(v) => setForm((p) => ({ ...p, labelTypeId: v }))} | |
| 823 | + options={typeOptions} | |
| 824 | + placeholder="Select type" | |
| 825 | + searchPlaceholder="Search type…" | |
| 826 | + emptyText="No types found." | |
| 827 | + disabled={refLoading} | |
| 828 | + /> | |
| 829 | + </div> | |
| 830 | + </div> | |
| 831 | + | |
| 832 | + <div className="space-y-2"> | |
| 833 | + <Label>Product * (multi-select)</Label> | |
| 834 | + <ProductMultiSelectField | |
| 835 | + value={form.productIds} | |
| 836 | + onChange={(next) => setForm((p) => ({ ...p, productIds: next }))} | |
| 837 | + disabled={refLoading} | |
| 838 | + productOptions={productOptions} | |
| 839 | + /> | |
| 840 | + </div> | |
| 841 | + | |
| 842 | + <div | |
| 843 | + className="flex items-center justify-between border border-gray-200 rounded-md px-3 bg-white" | |
| 844 | + style={{ height: 40 }} | |
| 845 | + > | |
| 846 | + <div className="text-sm font-medium text-gray-900">Enabled</div> | |
| 847 | + <Switch checked={form.state} onCheckedChange={(checked) => setForm((p) => ({ ...p, state: checked }))} /> | |
| 848 | + </div> | |
| 849 | + </div> | |
| 850 | + | |
| 851 | + <DialogFooter> | |
| 852 | + <Button variant="outline" onClick={() => onOpenChange(false)}> | |
| 853 | + Cancel | |
| 854 | + </Button> | |
| 855 | + <Button disabled={submitting || refLoading} onClick={submit}> | |
| 856 | + {submitting ? "Creating…" : "Create"} | |
| 857 | + </Button> | |
| 858 | + </DialogFooter> | |
| 859 | + </DialogContent> | |
| 860 | + </Dialog> | |
| 861 | + ); | |
| 862 | +} | |
| 863 | + | |
| 864 | +function EditLabelDialog({ | |
| 865 | + open, | |
| 866 | + label, | |
| 867 | + onOpenChange, | |
| 868 | + onUpdated, | |
| 869 | +}: { | |
| 870 | + open: boolean; | |
| 871 | + label: LabelDto | null; | |
| 872 | + onOpenChange: (open: boolean) => void; | |
| 873 | + onUpdated: () => void; | |
| 874 | +}) { | |
| 875 | + const { loading: refLoading, templates, locations, categories, types, productOptions } = | |
| 876 | + useLabelFormReferenceData(open); | |
| 877 | + const [submitting, setSubmitting] = useState(false); | |
| 878 | + const [detailLoading, setDetailLoading] = useState(false); | |
| 879 | + const [form, setForm] = useState<LabelUpdateInput>({ | |
| 880 | + labelName: "", | |
| 881 | + templateCode: "", | |
| 882 | + locationId: "", | |
| 883 | + labelCategoryId: "", | |
| 884 | + labelTypeId: "", | |
| 885 | + productIds: [], | |
| 886 | + labelInfoJson: null, | |
| 887 | + state: true, | |
| 888 | + }); | |
| 889 | + | |
| 890 | + useEffect(() => { | |
| 891 | + if (!open || !label?.id) return; | |
| 892 | + | |
| 893 | + const id = label.id; | |
| 894 | + setForm(labelDtoToUpdateForm(label)); | |
| 895 | + | |
| 896 | + const ac = new AbortController(); | |
| 897 | + let cancelled = false; | |
| 898 | + setDetailLoading(true); | |
| 899 | + (async () => { | |
| 900 | + try { | |
| 901 | + const detail = await getLabel(id, ac.signal); | |
| 902 | + if (cancelled) return; | |
| 903 | + setForm(labelDtoToUpdateForm(detail)); | |
| 904 | + } catch (e: any) { | |
| 905 | + if (cancelled || e?.name === "AbortError") return; | |
| 906 | + toast.error("Failed to load label details.", { | |
| 907 | + description: e?.message ? String(e.message) : "Form shows list data only; check network.", | |
| 908 | + }); | |
| 909 | + } finally { | |
| 910 | + if (!cancelled) setDetailLoading(false); | |
| 911 | + } | |
| 912 | + })(); | |
| 913 | + | |
| 914 | + return () => { | |
| 915 | + cancelled = true; | |
| 916 | + ac.abort(); | |
| 917 | + }; | |
| 918 | + }, [open, label]); | |
| 919 | + | |
| 920 | + const submit = async () => { | |
| 921 | + if (!label?.id) return; | |
| 922 | + if (!form.labelName.trim() || !form.templateCode.trim() || !form.locationId.trim() || !form.labelCategoryId.trim() || !form.labelTypeId.trim()) { | |
| 923 | + toast.error("Validation failed", { | |
| 924 | + description: "Fill all required fields and select template, location, category, and type.", | |
| 925 | + }); | |
| 926 | + return; | |
| 927 | + } | |
| 928 | + if (form.productIds.length === 0) { | |
| 929 | + toast.error("Validation failed", { | |
| 930 | + description: "Select at least one product.", | |
| 931 | + }); | |
| 932 | + return; | |
| 933 | + } | |
| 934 | + | |
| 935 | + setSubmitting(true); | |
| 936 | + try { | |
| 937 | + await updateLabel(label.id, form); | |
| 938 | + toast.success("Label updated.", { | |
| 939 | + description: "The label has been updated successfully.", | |
| 940 | + }); | |
| 941 | + onOpenChange(false); | |
| 942 | + onUpdated(); | |
| 943 | + } catch (e: any) { | |
| 944 | + toast.error("Failed to update label.", { | |
| 945 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 946 | + }); | |
| 947 | + } finally { | |
| 948 | + setSubmitting(false); | |
| 949 | + } | |
| 950 | + }; | |
| 951 | + | |
| 952 | + const editTemplateOptions = useMemo(() => { | |
| 953 | + const base = templates | |
| 954 | + .filter((t) => templateListCode(t)) | |
| 955 | + .map((t) => ({ | |
| 956 | + value: templateListCode(t), | |
| 957 | + label: templateListLabel(t), | |
| 958 | + })); | |
| 959 | + const c = form.templateCode; | |
| 960 | + if (c && !base.some((o) => o.value === c)) { | |
| 961 | + return [{ value: c, label: `${c} (current)` }, ...base]; | |
| 962 | + } | |
| 963 | + return base; | |
| 964 | + }, [templates, form.templateCode]); | |
| 965 | + | |
| 966 | + const editLocationOptions = useMemo(() => { | |
| 967 | + const base = locations.map((loc) => ({ | |
| 968 | + value: loc.id, | |
| 969 | + label: toDisplay(loc.locationName ?? loc.locationCode ?? loc.id), | |
| 970 | + })); | |
| 971 | + const id = form.locationId; | |
| 972 | + if (id && !base.some((o) => o.value === id)) { | |
| 973 | + return [{ value: id, label: `${id} (current)` }, ...base]; | |
| 974 | + } | |
| 975 | + return base; | |
| 976 | + }, [locations, form.locationId]); | |
| 977 | + | |
| 978 | + const editCategoryOptions = useMemo(() => { | |
| 979 | + const base = categories.map((c) => ({ | |
| 980 | + value: c.id, | |
| 981 | + label: toDisplay(c.categoryName ?? c.categoryCode ?? c.id), | |
| 982 | + })); | |
| 983 | + const id = form.labelCategoryId; | |
| 984 | + if (id && !base.some((o) => o.value === id)) { | |
| 985 | + return [{ value: id, label: `${id} (current)` }, ...base]; | |
| 986 | + } | |
| 987 | + return base; | |
| 988 | + }, [categories, form.labelCategoryId]); | |
| 989 | + | |
| 990 | + const editTypeOptions = useMemo(() => { | |
| 991 | + const base = types.map((ty) => ({ | |
| 992 | + value: ty.id, | |
| 993 | + label: toDisplay(ty.typeName ?? ty.typeCode ?? ty.id), | |
| 994 | + })); | |
| 995 | + const id = form.labelTypeId; | |
| 996 | + if (id && !base.some((o) => o.value === id)) { | |
| 997 | + return [{ value: id, label: `${id} (current)` }, ...base]; | |
| 998 | + } | |
| 999 | + return base; | |
| 1000 | + }, [types, form.labelTypeId]); | |
| 1001 | + | |
| 1002 | + return ( | |
| 1003 | + <Dialog open={open} onOpenChange={onOpenChange}> | |
| 1004 | + <DialogContent className="sm:max-w-[600px]"> | |
| 1005 | + <DialogHeader> | |
| 1006 | + <DialogTitle>Edit Label</DialogTitle> | |
| 1007 | + <DialogDescription> | |
| 1008 | + {detailLoading ? "Loading label details…" : "Update the label details."} | |
| 1009 | + </DialogDescription> | |
| 1010 | + </DialogHeader> | |
| 1011 | + | |
| 1012 | + <div className="grid gap-4 py-4"> | |
| 1013 | + <div className="space-y-2"> | |
| 1014 | + <Label>Label Name *</Label> | |
| 1015 | + <Input | |
| 1016 | + className="h-10" | |
| 1017 | + placeholder="e.g. Breakfast label" | |
| 1018 | + value={form.labelName} | |
| 1019 | + onChange={(e) => setForm((p) => ({ ...p, labelName: e.target.value }))} | |
| 1020 | + disabled={detailLoading} | |
| 1021 | + /> | |
| 1022 | + </div> | |
| 1023 | + | |
| 1024 | + <div className="grid grid-cols-2 gap-4"> | |
| 1025 | + <div className="space-y-2"> | |
| 1026 | + <Label>Label Template *</Label> | |
| 1027 | + <SearchableSelect | |
| 1028 | + value={form.templateCode} | |
| 1029 | + onValueChange={(v) => setForm((p) => ({ ...p, templateCode: v }))} | |
| 1030 | + options={editTemplateOptions} | |
| 1031 | + placeholder="Select template" | |
| 1032 | + searchPlaceholder="Search template…" | |
| 1033 | + emptyText="No templates found." | |
| 1034 | + disabled={refLoading || detailLoading} | |
| 1035 | + /> | |
| 1036 | + </div> | |
| 1037 | + <div className="space-y-2"> | |
| 1038 | + <Label>Location *</Label> | |
| 1039 | + <SearchableSelect | |
| 1040 | + value={form.locationId} | |
| 1041 | + onValueChange={(v) => setForm((p) => ({ ...p, locationId: v }))} | |
| 1042 | + options={editLocationOptions} | |
| 1043 | + placeholder="Select location" | |
| 1044 | + searchPlaceholder="Search location…" | |
| 1045 | + emptyText="No locations found." | |
| 1046 | + disabled={refLoading || detailLoading} | |
| 1047 | + /> | |
| 1048 | + </div> | |
| 1049 | + </div> | |
| 1050 | + | |
| 1051 | + <div className="grid grid-cols-2 gap-4"> | |
| 1052 | + <div className="space-y-2"> | |
| 1053 | + <Label>Label Category *</Label> | |
| 1054 | + <SearchableSelect | |
| 1055 | + value={form.labelCategoryId} | |
| 1056 | + onValueChange={(v) => setForm((p) => ({ ...p, labelCategoryId: v }))} | |
| 1057 | + options={editCategoryOptions} | |
| 1058 | + placeholder="Select category" | |
| 1059 | + searchPlaceholder="Search category…" | |
| 1060 | + emptyText="No categories found." | |
| 1061 | + disabled={refLoading || detailLoading} | |
| 1062 | + /> | |
| 1063 | + </div> | |
| 1064 | + <div className="space-y-2"> | |
| 1065 | + <Label>Label Type *</Label> | |
| 1066 | + <SearchableSelect | |
| 1067 | + value={form.labelTypeId} | |
| 1068 | + onValueChange={(v) => setForm((p) => ({ ...p, labelTypeId: v }))} | |
| 1069 | + options={editTypeOptions} | |
| 1070 | + placeholder="Select type" | |
| 1071 | + searchPlaceholder="Search type…" | |
| 1072 | + emptyText="No types found." | |
| 1073 | + disabled={refLoading || detailLoading} | |
| 1074 | + /> | |
| 1075 | + </div> | |
| 1076 | + </div> | |
| 1077 | + | |
| 1078 | + <div className="space-y-2"> | |
| 1079 | + <Label>Product * (multi-select)</Label> | |
| 1080 | + <ProductMultiSelectField | |
| 1081 | + value={form.productIds} | |
| 1082 | + onChange={(next) => setForm((p) => ({ ...p, productIds: next }))} | |
| 1083 | + disabled={refLoading || detailLoading} | |
| 1084 | + productOptions={productOptions} | |
| 1085 | + /> | |
| 1086 | + </div> | |
| 1087 | + | |
| 1088 | + <div | |
| 1089 | + className="flex items-center justify-between border border-gray-200 rounded-md px-3 bg-white" | |
| 1090 | + style={{ height: 40 }} | |
| 1091 | + > | |
| 1092 | + <div className="text-sm font-medium text-gray-900">Enabled</div> | |
| 1093 | + <Switch | |
| 1094 | + checked={form.state} | |
| 1095 | + onCheckedChange={(checked) => setForm((p) => ({ ...p, state: checked }))} | |
| 1096 | + disabled={detailLoading} | |
| 1097 | + /> | |
| 1098 | + </div> | |
| 1099 | + </div> | |
| 1100 | + | |
| 1101 | + <DialogFooter> | |
| 1102 | + <Button variant="outline" onClick={() => onOpenChange(false)}> | |
| 1103 | + Cancel | |
| 1104 | + </Button> | |
| 1105 | + <Button disabled={submitting || refLoading || detailLoading} onClick={submit}> | |
| 1106 | + {submitting ? "Updating…" : "Update"} | |
| 1107 | + </Button> | |
| 1108 | + </DialogFooter> | |
| 1109 | + </DialogContent> | |
| 1110 | + </Dialog> | |
| 1111 | + ); | |
| 1112 | +} | |
| 1113 | + | |
| 1114 | +function DeleteLabelDialog({ | |
| 1115 | + open, | |
| 1116 | + label, | |
| 1117 | + onOpenChange, | |
| 1118 | + onDeleted, | |
| 1119 | +}: { | |
| 1120 | + open: boolean; | |
| 1121 | + label: LabelDto | null; | |
| 1122 | + onOpenChange: (open: boolean) => void; | |
| 1123 | + onDeleted: () => void; | |
| 1124 | +}) { | |
| 1125 | + const [submitting, setSubmitting] = useState(false); | |
| 1126 | + | |
| 1127 | + const name = useMemo(() => { | |
| 1128 | + const n = (label?.labelName ?? "").trim(); | |
| 1129 | + return n || label?.labelCode || label?.id || "this label"; | |
| 1130 | + }, [label]); | |
| 1131 | + | |
| 1132 | + const submit = async () => { | |
| 1133 | + if (!label?.id) return; | |
| 1134 | + setSubmitting(true); | |
| 1135 | + try { | |
| 1136 | + await deleteLabel(label.id); | |
| 1137 | + toast.success("Label deleted.", { | |
| 1138 | + description: "The label has been removed successfully.", | |
| 1139 | + }); | |
| 1140 | + onOpenChange(false); | |
| 1141 | + onDeleted(); | |
| 1142 | + } catch (e: any) { | |
| 1143 | + toast.error("Failed to delete label.", { | |
| 1144 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 1145 | + }); | |
| 1146 | + } finally { | |
| 1147 | + setSubmitting(false); | |
| 1148 | + } | |
| 1149 | + }; | |
| 1150 | + | |
| 1151 | + return ( | |
| 1152 | + <Dialog open={open} onOpenChange={onOpenChange}> | |
| 1153 | + <DialogContent className="sm:max-w-none" style={{ width: "30%" }}> | |
| 1154 | + <DialogHeader> | |
| 1155 | + <DialogTitle>Delete Label</DialogTitle> | |
| 1156 | + <DialogDescription>This action cannot be undone.</DialogDescription> | |
| 1157 | + </DialogHeader> | |
| 1158 | + | |
| 1159 | + <div className="text-sm text-gray-700"> | |
| 1160 | + Are you sure you want to delete <span className="font-medium">{name}</span>? | |
| 1161 | + </div> | |
| 1162 | + | |
| 1163 | + <DialogFooter className="flex-row flex-wrap justify-end"> | |
| 1164 | + <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}> | |
| 1165 | + Cancel | |
| 1166 | + </Button> | |
| 1167 | + <Button | |
| 1168 | + className="min-w-24" | |
| 1169 | + variant="destructive" | |
| 1170 | + disabled={submitting} | |
| 1171 | + onClick={submit} | |
| 1172 | + > | |
| 1173 | + {submitting ? "Deleting..." : "Delete"} | |
| 1174 | + </Button> | |
| 1175 | + </DialogFooter> | |
| 1176 | + </DialogContent> | |
| 1177 | + </Dialog> | |
| 1178 | + ); | |
| 1179 | +} | ... | ... |
美国版/Food Labeling Management Platform/src/components/labels/MultipleOptionsView.tsx
| 1 | -import React from 'react'; | |
| 1 | +import React, { useEffect, useMemo, useRef, useState } from 'react'; | |
| 2 | 2 | import { |
| 3 | 3 | Table, |
| 4 | 4 | TableBody, |
| ... | ... | @@ -16,75 +16,784 @@ import { |
| 16 | 16 | SelectTrigger, |
| 17 | 17 | SelectValue, |
| 18 | 18 | } from "../ui/select"; |
| 19 | -import { Plus } from "lucide-react"; | |
| 19 | +import { | |
| 20 | + Dialog, | |
| 21 | + DialogContent, | |
| 22 | + DialogDescription, | |
| 23 | + DialogFooter, | |
| 24 | + DialogHeader, | |
| 25 | + DialogTitle, | |
| 26 | +} from "../ui/dialog"; | |
| 27 | +import { Label } from "../ui/label"; | |
| 28 | +import { Switch } from "../ui/switch"; | |
| 29 | +import { Badge } from "../ui/badge"; | |
| 30 | +import { Plus, Edit, MoreHorizontal, X } from "lucide-react"; | |
| 31 | +import { toast } from "sonner"; | |
| 32 | +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | |
| 33 | +import { | |
| 34 | + Pagination, | |
| 35 | + PaginationContent, | |
| 36 | + PaginationItem, | |
| 37 | + PaginationLink, | |
| 38 | + PaginationNext, | |
| 39 | + PaginationPrevious, | |
| 40 | +} from "../ui/pagination"; | |
| 41 | +import { | |
| 42 | + getLabelMultipleOptions, | |
| 43 | + getLabelMultipleOption, | |
| 44 | + createLabelMultipleOption, | |
| 45 | + updateLabelMultipleOption, | |
| 46 | + deleteLabelMultipleOption, | |
| 47 | +} from "../../services/labelMultipleOptionService"; | |
| 48 | +import type { | |
| 49 | + LabelMultipleOptionDto, | |
| 50 | + LabelMultipleOptionCreateInput, | |
| 51 | + LabelMultipleOptionUpdateInput, | |
| 52 | +} from "../../types/labelMultipleOption"; | |
| 53 | + | |
| 54 | +function toDisplay(v: string | null | undefined): string { | |
| 55 | + const s = (v ?? "").trim(); | |
| 56 | + return s ? s : "None"; | |
| 57 | +} | |
| 20 | 58 | |
| 21 | 59 | export function MultipleOptionsView() { |
| 22 | - const options = [ | |
| 23 | - { | |
| 24 | - id: 1, | |
| 25 | - name: 'Prepped By', | |
| 26 | - contents: 'A. Smith; B. Doe; C. Borne', | |
| 27 | - lastEdited: '2025.12.03.11:45', | |
| 28 | - }, | |
| 29 | - { | |
| 30 | - id: 2, | |
| 31 | - name: 'Checked By', | |
| 32 | - contents: 'D. Manager; E. Supervisor', | |
| 33 | - lastEdited: '2025.12.04.09:30', | |
| 34 | - }, | |
| 35 | - { | |
| 36 | - id: 3, | |
| 37 | - name: 'Allergens', | |
| 38 | - contents: 'Peanuts; Dairy; Gluten; Soy', | |
| 39 | - lastEdited: '2025.12.05.14:15', | |
| 40 | - }, | |
| 41 | - ]; | |
| 60 | + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); | |
| 61 | + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); | |
| 62 | + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); | |
| 63 | + const [editingOption, setEditingOption] = useState<LabelMultipleOptionDto | null>(null); | |
| 64 | + const [deletingOption, setDeletingOption] = useState<LabelMultipleOptionDto | null>(null); | |
| 65 | + const [options, setOptions] = useState<LabelMultipleOptionDto[]>([]); | |
| 66 | + const [loading, setLoading] = useState(false); | |
| 67 | + const [total, setTotal] = useState(0); | |
| 68 | + const [refreshSeq, setRefreshSeq] = useState(0); | |
| 69 | + const [actionsOpenForId, setActionsOpenForId] = useState<string | null>(null); | |
| 70 | + | |
| 71 | + const [keyword, setKeyword] = useState(""); | |
| 72 | + const [stateFilter, setStateFilter] = useState<string>("all"); | |
| 73 | + | |
| 74 | + const [pageIndex, setPageIndex] = useState(1); | |
| 75 | + const [pageSize, setPageSize] = useState(10); | |
| 76 | + | |
| 77 | + const abortRef = useRef<AbortController | null>(null); | |
| 78 | + const keywordTimerRef = useRef<number | null>(null); | |
| 79 | + const [debouncedKeyword, setDebouncedKeyword] = useState(""); | |
| 80 | + | |
| 81 | + useEffect(() => { | |
| 82 | + if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); | |
| 83 | + keywordTimerRef.current = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300); | |
| 84 | + return () => { | |
| 85 | + if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); | |
| 86 | + }; | |
| 87 | + }, [keyword]); | |
| 88 | + | |
| 89 | + const totalPages = Math.max(1, Math.ceil(total / pageSize)); | |
| 90 | + | |
| 91 | + useEffect(() => { | |
| 92 | + setPageIndex(1); | |
| 93 | + }, [debouncedKeyword, stateFilter, pageSize]); | |
| 94 | + | |
| 95 | + useEffect(() => { | |
| 96 | + const run = async () => { | |
| 97 | + abortRef.current?.abort(); | |
| 98 | + const ac = new AbortController(); | |
| 99 | + abortRef.current = ac; | |
| 100 | + | |
| 101 | + setLoading(true); | |
| 102 | + try { | |
| 103 | + const skipCount = (pageIndex - 1) * pageSize; | |
| 104 | + const res = await getLabelMultipleOptions( | |
| 105 | + { | |
| 106 | + skipCount, | |
| 107 | + maxResultCount: pageSize, | |
| 108 | + keyword: debouncedKeyword || undefined, | |
| 109 | + state: stateFilter === "all" ? undefined : stateFilter === "true", | |
| 110 | + }, | |
| 111 | + ac.signal, | |
| 112 | + ); | |
| 113 | + | |
| 114 | + setOptions(res.items ?? []); | |
| 115 | + setTotal(res.totalCount ?? 0); | |
| 116 | + } catch (e: any) { | |
| 117 | + if (e?.name === "AbortError") return; | |
| 118 | + toast.error("Failed to load multiple options.", { | |
| 119 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 120 | + }); | |
| 121 | + setOptions([]); | |
| 122 | + setTotal(0); | |
| 123 | + } finally { | |
| 124 | + setLoading(false); | |
| 125 | + } | |
| 126 | + }; | |
| 127 | + | |
| 128 | + run(); | |
| 129 | + return () => abortRef.current?.abort(); | |
| 130 | + }, [debouncedKeyword, stateFilter, pageIndex, pageSize, refreshSeq]); | |
| 131 | + | |
| 132 | + const refreshList = () => setRefreshSeq((x) => x + 1); | |
| 133 | + | |
| 134 | + const openEdit = (opt: LabelMultipleOptionDto) => { | |
| 135 | + setActionsOpenForId(null); | |
| 136 | + setEditingOption(opt); | |
| 137 | + setIsEditDialogOpen(true); | |
| 138 | + }; | |
| 139 | + | |
| 140 | + const openDelete = (opt: LabelMultipleOptionDto) => { | |
| 141 | + setActionsOpenForId(null); | |
| 142 | + setDeletingOption(opt); | |
| 143 | + setIsDeleteDialogOpen(true); | |
| 144 | + }; | |
| 42 | 145 | |
| 43 | 146 | return ( |
| 44 | - <div className="space-y-6"> | |
| 45 | - {/* Top Controls - single row, style consistent with other Label views */} | |
| 46 | - <div className="flex flex-nowrap items-center gap-3"> | |
| 47 | - <Input | |
| 48 | - placeholder="Search" | |
| 49 | - style={{ height: 40, boxSizing: 'border-box' }} | |
| 50 | - className="bg-white border border-gray-300 rounded-md w-40 shrink-0 placeholder:text-gray-500" | |
| 51 | - /> | |
| 52 | - <Select defaultValue="all"> | |
| 53 | - <SelectTrigger className="bg-white border border-gray-300 rounded-md w-[200px] shrink-0" style={{ height: 40, boxSizing: 'border-box' }}> | |
| 54 | - <SelectValue placeholder="Location" /> | |
| 55 | - </SelectTrigger> | |
| 56 | - <SelectContent> | |
| 57 | - <SelectItem value="all">All Locations</SelectItem> | |
| 58 | - <SelectItem value="loc-a">Location A</SelectItem> | |
| 59 | - <SelectItem value="loc-b">Location B</SelectItem> | |
| 60 | - </SelectContent> | |
| 61 | - </Select> | |
| 62 | - <Button className="bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md h-10 px-6 shrink-0 ml-auto"> | |
| 63 | - New Multiple Options <Plus className="ml-1 h-4 w-4" /> | |
| 64 | - </Button> | |
| 147 | + <div className="h-full flex flex-col"> | |
| 148 | + <div className="pb-4"> | |
| 149 | + <div className="flex flex-col gap-4"> | |
| 150 | + <div className="flex flex-nowrap items-center gap-3"> | |
| 151 | + <Input | |
| 152 | + placeholder="Search" | |
| 153 | + value={keyword} | |
| 154 | + onChange={(e) => setKeyword(e.target.value)} | |
| 155 | + style={{ height: 40, boxSizing: 'border-box' }} | |
| 156 | + className="bg-white border border-gray-300 rounded-md w-40 shrink-0 placeholder:text-gray-500" | |
| 157 | + /> | |
| 158 | + <Select value={stateFilter} onValueChange={setStateFilter}> | |
| 159 | + <SelectTrigger className="bg-white border border-gray-300 rounded-md w-[150px] shrink-0" style={{ height: 40, boxSizing: 'border-box' }}> | |
| 160 | + <SelectValue placeholder="State" /> | |
| 161 | + </SelectTrigger> | |
| 162 | + <SelectContent> | |
| 163 | + <SelectItem value="all">All States</SelectItem> | |
| 164 | + <SelectItem value="true">Active</SelectItem> | |
| 165 | + <SelectItem value="false">Inactive</SelectItem> | |
| 166 | + </SelectContent> | |
| 167 | + </Select> | |
| 168 | + <div className="flex-1" /> | |
| 169 | + <Button | |
| 170 | + className="bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md h-10 px-6 shrink-0" | |
| 171 | + onClick={() => setIsCreateDialogOpen(true)} | |
| 172 | + > | |
| 173 | + New Multiple Options <Plus className="ml-1 h-4 w-4" /> | |
| 174 | + </Button> | |
| 175 | + </div> | |
| 176 | + </div> | |
| 65 | 177 | </div> |
| 66 | 178 | |
| 67 | - {/* Table */} | |
| 68 | - <div className="rounded-md border bg-white shadow-sm"> | |
| 69 | - <Table> | |
| 70 | - <TableHeader> | |
| 71 | - <TableRow className="bg-gray-50 hover:bg-gray-50"> | |
| 72 | - <TableHead className="font-bold text-gray-900 w-[200px]">Multiple Option Name</TableHead> | |
| 73 | - <TableHead className="font-bold text-gray-900">Contents</TableHead> | |
| 74 | - <TableHead className="font-bold text-gray-900 w-[180px]">Last Edited</TableHead> | |
| 75 | - </TableRow> | |
| 76 | - </TableHeader> | |
| 77 | - <TableBody> | |
| 78 | - {options.map((item) => ( | |
| 79 | - <TableRow key={item.id} className="hover:bg-gray-50"> | |
| 80 | - <TableCell className="font-medium">{item.name}</TableCell> | |
| 81 | - <TableCell className="text-gray-600">{item.contents}</TableCell> | |
| 82 | - <TableCell className="text-gray-500 tabular-nums font-numeric">{item.lastEdited}</TableCell> | |
| 179 | + <div className="flex-1 overflow-auto pt-6"> | |
| 180 | + <div className="rounded-md border bg-white shadow-sm"> | |
| 181 | + <Table> | |
| 182 | + <TableHeader> | |
| 183 | + <TableRow className="bg-gray-50 hover:bg-gray-50"> | |
| 184 | + <TableHead className="font-bold text-gray-900 w-[200px]">Multiple Option Name</TableHead> | |
| 185 | + <TableHead className="font-bold text-gray-900 w-[200px]">Option Code</TableHead> | |
| 186 | + <TableHead className="font-bold text-gray-900">Contents</TableHead> | |
| 187 | + <TableHead className="font-bold text-gray-900 w-[100px]">State</TableHead> | |
| 188 | + <TableHead className="font-bold text-gray-900 w-[100px]">Order</TableHead> | |
| 189 | + <TableHead className="font-bold text-gray-900 w-[180px]">Last Edited</TableHead> | |
| 190 | + <TableHead className="font-bold text-gray-900 text-center w-[100px]">Actions</TableHead> | |
| 83 | 191 | </TableRow> |
| 84 | - ))} | |
| 85 | - </TableBody> | |
| 86 | - </Table> | |
| 192 | + </TableHeader> | |
| 193 | + <TableBody> | |
| 194 | + {loading ? ( | |
| 195 | + <TableRow> | |
| 196 | + <TableCell colSpan={7} className="text-center text-sm text-gray-500 py-10"> | |
| 197 | + Loading... | |
| 198 | + </TableCell> | |
| 199 | + </TableRow> | |
| 200 | + ) : options.length === 0 ? ( | |
| 201 | + <TableRow> | |
| 202 | + <TableCell colSpan={7} className="text-center text-sm text-gray-500 py-10"> | |
| 203 | + No results. | |
| 204 | + </TableCell> | |
| 205 | + </TableRow> | |
| 206 | + ) : ( | |
| 207 | + options.map((item) => ( | |
| 208 | + <TableRow key={item.id} className="hover:bg-gray-50"> | |
| 209 | + <TableCell className="font-medium">{toDisplay(item.optionName)}</TableCell> | |
| 210 | + <TableCell className="text-gray-600">{toDisplay(item.optionCode)}</TableCell> | |
| 211 | + <TableCell className="text-gray-600"> | |
| 212 | + {item.optionValuesJson && item.optionValuesJson.length > 0 | |
| 213 | + ? item.optionValuesJson.join("; ") | |
| 214 | + : "None"} | |
| 215 | + </TableCell> | |
| 216 | + <TableCell> | |
| 217 | + <Badge className={item.state ? "bg-green-600" : "bg-gray-400"}> | |
| 218 | + {item.state ? "Active" : "Inactive"} | |
| 219 | + </Badge> | |
| 220 | + </TableCell> | |
| 221 | + <TableCell className="font-numeric">{item.orderNum ?? "None"}</TableCell> | |
| 222 | + <TableCell className="text-gray-500 tabular-nums font-numeric"> | |
| 223 | + {item.creationTime ? new Date(item.creationTime).toLocaleString() : "None"} | |
| 224 | + </TableCell> | |
| 225 | + <TableCell className="text-center"> | |
| 226 | + <Popover | |
| 227 | + open={actionsOpenForId === item.id} | |
| 228 | + onOpenChange={(open) => setActionsOpenForId(open ? item.id : null)} | |
| 229 | + > | |
| 230 | + <PopoverTrigger asChild> | |
| 231 | + <Button | |
| 232 | + type="button" | |
| 233 | + variant="ghost" | |
| 234 | + size="icon" | |
| 235 | + className="h-8 w-8" | |
| 236 | + aria-label="Row actions" | |
| 237 | + > | |
| 238 | + <MoreHorizontal className="h-4 w-4 text-gray-500" /> | |
| 239 | + </Button> | |
| 240 | + </PopoverTrigger> | |
| 241 | + <PopoverContent align="end" className="w-40 p-1"> | |
| 242 | + <Button | |
| 243 | + type="button" | |
| 244 | + variant="ghost" | |
| 245 | + className="w-full justify-start gap-2 h-9 px-2 font-normal" | |
| 246 | + onClick={() => openEdit(item)} | |
| 247 | + > | |
| 248 | + <Edit className="w-4 h-4" /> | |
| 249 | + Edit | |
| 250 | + </Button> | |
| 251 | + <Button | |
| 252 | + type="button" | |
| 253 | + variant="ghost" | |
| 254 | + className="w-full justify-start h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50" | |
| 255 | + onClick={() => openDelete(item)} | |
| 256 | + > | |
| 257 | + Delete | |
| 258 | + </Button> | |
| 259 | + </PopoverContent> | |
| 260 | + </Popover> | |
| 261 | + </TableCell> | |
| 262 | + </TableRow> | |
| 263 | + )) | |
| 264 | + )} | |
| 265 | + </TableBody> | |
| 266 | + </Table> | |
| 267 | + </div> | |
| 268 | + </div> | |
| 269 | + | |
| 270 | + <div className="pt-4"> | |
| 271 | + <div className="flex items-center justify-between text-sm text-gray-600"> | |
| 272 | + <div> | |
| 273 | + Showing {total === 0 ? 0 : (pageIndex - 1) * pageSize + 1}- | |
| 274 | + {Math.min(pageIndex * pageSize, total)} of {total} | |
| 275 | + </div> | |
| 276 | + <div className="flex items-center gap-3"> | |
| 277 | + <Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}> | |
| 278 | + <SelectTrigger className="w-[110px] h-9 rounded-md border border-gray-300 bg-white text-gray-900"> | |
| 279 | + <SelectValue /> | |
| 280 | + </SelectTrigger> | |
| 281 | + <SelectContent> | |
| 282 | + {[10, 20, 50].map((n) => ( | |
| 283 | + <SelectItem key={n} value={String(n)}> | |
| 284 | + {n} / page | |
| 285 | + </SelectItem> | |
| 286 | + ))} | |
| 287 | + </SelectContent> | |
| 288 | + </Select> | |
| 289 | + <Pagination className="mx-0 w-auto justify-end"> | |
| 290 | + <PaginationContent> | |
| 291 | + <PaginationItem> | |
| 292 | + <PaginationPrevious | |
| 293 | + href="#" | |
| 294 | + size="default" | |
| 295 | + onClick={(e) => { | |
| 296 | + e.preventDefault(); | |
| 297 | + setPageIndex((p) => Math.max(1, p - 1)); | |
| 298 | + }} | |
| 299 | + aria-disabled={pageIndex <= 1} | |
| 300 | + className={pageIndex <= 1 ? "pointer-events-none opacity-50" : ""} | |
| 301 | + /> | |
| 302 | + </PaginationItem> | |
| 303 | + <PaginationItem> | |
| 304 | + <PaginationLink | |
| 305 | + href="#" | |
| 306 | + isActive | |
| 307 | + size="default" | |
| 308 | + onClick={(e) => e.preventDefault()} | |
| 309 | + > | |
| 310 | + Page {pageIndex} / {totalPages} | |
| 311 | + </PaginationLink> | |
| 312 | + </PaginationItem> | |
| 313 | + <PaginationItem> | |
| 314 | + <PaginationNext | |
| 315 | + href="#" | |
| 316 | + size="default" | |
| 317 | + onClick={(e) => { | |
| 318 | + e.preventDefault(); | |
| 319 | + setPageIndex((p) => Math.min(totalPages, p + 1)); | |
| 320 | + }} | |
| 321 | + aria-disabled={pageIndex >= totalPages} | |
| 322 | + className={pageIndex >= totalPages ? "pointer-events-none opacity-50" : ""} | |
| 323 | + /> | |
| 324 | + </PaginationItem> | |
| 325 | + </PaginationContent> | |
| 326 | + </Pagination> | |
| 327 | + </div> | |
| 328 | + </div> | |
| 87 | 329 | </div> |
| 330 | + | |
| 331 | + <CreateMultipleOptionDialog | |
| 332 | + open={isCreateDialogOpen} | |
| 333 | + onOpenChange={setIsCreateDialogOpen} | |
| 334 | + onCreated={() => { | |
| 335 | + setPageIndex(1); | |
| 336 | + refreshList(); | |
| 337 | + }} | |
| 338 | + /> | |
| 339 | + | |
| 340 | + <EditMultipleOptionDialog | |
| 341 | + open={isEditDialogOpen} | |
| 342 | + option={editingOption} | |
| 343 | + onOpenChange={(open) => { | |
| 344 | + setIsEditDialogOpen(open); | |
| 345 | + if (!open) setEditingOption(null); | |
| 346 | + }} | |
| 347 | + onUpdated={refreshList} | |
| 348 | + /> | |
| 349 | + | |
| 350 | + <DeleteMultipleOptionDialog | |
| 351 | + open={isDeleteDialogOpen} | |
| 352 | + option={deletingOption} | |
| 353 | + onOpenChange={(open) => { | |
| 354 | + setIsDeleteDialogOpen(open); | |
| 355 | + if (!open) setDeletingOption(null); | |
| 356 | + }} | |
| 357 | + onDeleted={refreshList} | |
| 358 | + /> | |
| 88 | 359 | </div> |
| 89 | 360 | ); |
| 90 | 361 | } |
| 362 | + | |
| 363 | +function CreateMultipleOptionDialog({ | |
| 364 | + open, | |
| 365 | + onOpenChange, | |
| 366 | + onCreated, | |
| 367 | +}: { | |
| 368 | + open: boolean; | |
| 369 | + onOpenChange: (open: boolean) => void; | |
| 370 | + onCreated: () => void; | |
| 371 | +}) { | |
| 372 | + const [submitting, setSubmitting] = useState(false); | |
| 373 | + const [form, setForm] = useState<LabelMultipleOptionCreateInput>({ | |
| 374 | + optionCode: "", | |
| 375 | + optionName: "", | |
| 376 | + optionValuesJson: [], | |
| 377 | + state: true, | |
| 378 | + orderNum: null, | |
| 379 | + }); | |
| 380 | + const [newValue, setNewValue] = useState(""); | |
| 381 | + | |
| 382 | + const resetForm = () => { | |
| 383 | + setForm({ | |
| 384 | + optionCode: "", | |
| 385 | + optionName: "", | |
| 386 | + optionValuesJson: [], | |
| 387 | + state: true, | |
| 388 | + orderNum: null, | |
| 389 | + }); | |
| 390 | + setNewValue(""); | |
| 391 | + }; | |
| 392 | + | |
| 393 | + useEffect(() => { | |
| 394 | + if (!open) { | |
| 395 | + resetForm(); | |
| 396 | + } | |
| 397 | + }, [open]); | |
| 398 | + | |
| 399 | + const addValue = () => { | |
| 400 | + const trimmed = newValue.trim(); | |
| 401 | + if (!trimmed) return; | |
| 402 | + if (form.optionValuesJson.includes(trimmed)) { | |
| 403 | + toast.error("Duplicate value", { | |
| 404 | + description: "This value already exists.", | |
| 405 | + }); | |
| 406 | + return; | |
| 407 | + } | |
| 408 | + setForm((p) => ({ | |
| 409 | + ...p, | |
| 410 | + optionValuesJson: [...p.optionValuesJson, trimmed], | |
| 411 | + })); | |
| 412 | + setNewValue(""); | |
| 413 | + }; | |
| 414 | + | |
| 415 | + const removeValue = (index: number) => { | |
| 416 | + setForm((p) => ({ | |
| 417 | + ...p, | |
| 418 | + optionValuesJson: p.optionValuesJson.filter((_, i) => i !== index), | |
| 419 | + })); | |
| 420 | + }; | |
| 421 | + | |
| 422 | + const submit = async () => { | |
| 423 | + if (!form.optionCode.trim() || !form.optionName.trim()) { | |
| 424 | + toast.error("Validation failed", { | |
| 425 | + description: "Option Code and Option Name are required.", | |
| 426 | + }); | |
| 427 | + return; | |
| 428 | + } | |
| 429 | + if (form.optionValuesJson.length === 0) { | |
| 430 | + toast.error("Validation failed", { | |
| 431 | + description: "At least one option value is required.", | |
| 432 | + }); | |
| 433 | + return; | |
| 434 | + } | |
| 435 | + | |
| 436 | + setSubmitting(true); | |
| 437 | + try { | |
| 438 | + await createLabelMultipleOption(form); | |
| 439 | + toast.success("Multiple option created.", { | |
| 440 | + description: "The multiple option has been created successfully.", | |
| 441 | + }); | |
| 442 | + onOpenChange(false); | |
| 443 | + onCreated(); | |
| 444 | + } catch (e: any) { | |
| 445 | + toast.error("Failed to create multiple option.", { | |
| 446 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 447 | + }); | |
| 448 | + } finally { | |
| 449 | + setSubmitting(false); | |
| 450 | + } | |
| 451 | + }; | |
| 452 | + | |
| 453 | + return ( | |
| 454 | + <Dialog open={open} onOpenChange={onOpenChange}> | |
| 455 | + <DialogContent className="sm:max-w-[600px]"> | |
| 456 | + <DialogHeader> | |
| 457 | + <DialogTitle>Add New Multiple Option</DialogTitle> | |
| 458 | + <DialogDescription> | |
| 459 | + Enter the details for the new multiple option. | |
| 460 | + </DialogDescription> | |
| 461 | + </DialogHeader> | |
| 462 | + | |
| 463 | + <div className="grid gap-4 py-4"> | |
| 464 | + <div className="grid grid-cols-2 gap-4"> | |
| 465 | + <div className="space-y-2"> | |
| 466 | + <Label>Option Code *</Label> | |
| 467 | + <Input | |
| 468 | + placeholder="e.g. OPT_ALLERGENS" | |
| 469 | + value={form.optionCode} | |
| 470 | + onChange={(e) => setForm((p) => ({ ...p, optionCode: e.target.value }))} | |
| 471 | + /> | |
| 472 | + </div> | |
| 473 | + <div className="space-y-2"> | |
| 474 | + <Label>Option Name *</Label> | |
| 475 | + <Input | |
| 476 | + placeholder="e.g. Allergens" | |
| 477 | + value={form.optionName} | |
| 478 | + onChange={(e) => setForm((p) => ({ ...p, optionName: e.target.value }))} | |
| 479 | + /> | |
| 480 | + </div> | |
| 481 | + </div> | |
| 482 | + | |
| 483 | + <div className="space-y-2"> | |
| 484 | + <Label>Option Values *</Label> | |
| 485 | + <div className="flex gap-2"> | |
| 486 | + <Input | |
| 487 | + placeholder="Enter a value and press Add" | |
| 488 | + value={newValue} | |
| 489 | + onChange={(e) => setNewValue(e.target.value)} | |
| 490 | + onKeyDown={(e) => { | |
| 491 | + if (e.key === "Enter") { | |
| 492 | + e.preventDefault(); | |
| 493 | + addValue(); | |
| 494 | + } | |
| 495 | + }} | |
| 496 | + /> | |
| 497 | + <Button type="button" onClick={addValue} variant="outline"> | |
| 498 | + Add | |
| 499 | + </Button> | |
| 500 | + </div> | |
| 501 | + {form.optionValuesJson.length > 0 && ( | |
| 502 | + <div className="flex flex-wrap gap-2 mt-2"> | |
| 503 | + {form.optionValuesJson.map((val, idx) => ( | |
| 504 | + <Badge key={idx} variant="secondary" className="flex items-center gap-1"> | |
| 505 | + {val} | |
| 506 | + <button | |
| 507 | + type="button" | |
| 508 | + onClick={() => removeValue(idx)} | |
| 509 | + className="ml-1 hover:text-red-600" | |
| 510 | + > | |
| 511 | + <X className="h-3 w-3" /> | |
| 512 | + </button> | |
| 513 | + </Badge> | |
| 514 | + ))} | |
| 515 | + </div> | |
| 516 | + )} | |
| 517 | + </div> | |
| 518 | + | |
| 519 | + <div className="grid grid-cols-2 gap-4"> | |
| 520 | + <div className="space-y-2"> | |
| 521 | + <Label>Order</Label> | |
| 522 | + <Input | |
| 523 | + type="number" | |
| 524 | + placeholder="e.g. 1" | |
| 525 | + value={form.orderNum ?? ""} | |
| 526 | + onChange={(e) => setForm((p) => ({ ...p, orderNum: e.target.value ? Number(e.target.value) : null }))} | |
| 527 | + /> | |
| 528 | + </div> | |
| 529 | + <div className="flex items-center justify-between border border-gray-200 rounded-md px-3 bg-white" style={{ height: 40 }}> | |
| 530 | + <div className="text-sm font-medium text-gray-900">Enabled</div> | |
| 531 | + <Switch checked={form.state} onCheckedChange={(checked) => setForm((p) => ({ ...p, state: checked }))} /> | |
| 532 | + </div> | |
| 533 | + </div> | |
| 534 | + </div> | |
| 535 | + | |
| 536 | + <DialogFooter> | |
| 537 | + <Button variant="outline" onClick={() => onOpenChange(false)}> | |
| 538 | + Cancel | |
| 539 | + </Button> | |
| 540 | + <Button disabled={submitting} onClick={submit}> | |
| 541 | + {submitting ? "Creating..." : "Create"} | |
| 542 | + </Button> | |
| 543 | + </DialogFooter> | |
| 544 | + </DialogContent> | |
| 545 | + </Dialog> | |
| 546 | + ); | |
| 547 | +} | |
| 548 | + | |
| 549 | +function EditMultipleOptionDialog({ | |
| 550 | + open, | |
| 551 | + option, | |
| 552 | + onOpenChange, | |
| 553 | + onUpdated, | |
| 554 | +}: { | |
| 555 | + open: boolean; | |
| 556 | + option: LabelMultipleOptionDto | null; | |
| 557 | + onOpenChange: (open: boolean) => void; | |
| 558 | + onUpdated: () => void; | |
| 559 | +}) { | |
| 560 | + const [submitting, setSubmitting] = useState(false); | |
| 561 | + const [form, setForm] = useState<LabelMultipleOptionUpdateInput>({ | |
| 562 | + optionCode: "", | |
| 563 | + optionName: "", | |
| 564 | + optionValuesJson: [], | |
| 565 | + state: true, | |
| 566 | + orderNum: null, | |
| 567 | + }); | |
| 568 | + const [newValue, setNewValue] = useState(""); | |
| 569 | + | |
| 570 | + useEffect(() => { | |
| 571 | + if (open && option) { | |
| 572 | + setForm({ | |
| 573 | + optionCode: option.optionCode ?? "", | |
| 574 | + optionName: option.optionName ?? "", | |
| 575 | + optionValuesJson: option.optionValuesJson ?? [], | |
| 576 | + state: option.state ?? true, | |
| 577 | + orderNum: option.orderNum ?? null, | |
| 578 | + }); | |
| 579 | + setNewValue(""); | |
| 580 | + } | |
| 581 | + }, [open, option]); | |
| 582 | + | |
| 583 | + const addValue = () => { | |
| 584 | + const trimmed = newValue.trim(); | |
| 585 | + if (!trimmed) return; | |
| 586 | + if (form.optionValuesJson.includes(trimmed)) { | |
| 587 | + toast.error("Duplicate value", { | |
| 588 | + description: "This value already exists.", | |
| 589 | + }); | |
| 590 | + return; | |
| 591 | + } | |
| 592 | + setForm((p) => ({ | |
| 593 | + ...p, | |
| 594 | + optionValuesJson: [...p.optionValuesJson, trimmed], | |
| 595 | + })); | |
| 596 | + setNewValue(""); | |
| 597 | + }; | |
| 598 | + | |
| 599 | + const removeValue = (index: number) => { | |
| 600 | + setForm((p) => ({ | |
| 601 | + ...p, | |
| 602 | + optionValuesJson: p.optionValuesJson.filter((_, i) => i !== index), | |
| 603 | + })); | |
| 604 | + }; | |
| 605 | + | |
| 606 | + const submit = async () => { | |
| 607 | + if (!option?.id) return; | |
| 608 | + if (!form.optionCode.trim() || !form.optionName.trim()) { | |
| 609 | + toast.error("Validation failed", { | |
| 610 | + description: "Option Code and Option Name are required.", | |
| 611 | + }); | |
| 612 | + return; | |
| 613 | + } | |
| 614 | + if (form.optionValuesJson.length === 0) { | |
| 615 | + toast.error("Validation failed", { | |
| 616 | + description: "At least one option value is required.", | |
| 617 | + }); | |
| 618 | + return; | |
| 619 | + } | |
| 620 | + | |
| 621 | + setSubmitting(true); | |
| 622 | + try { | |
| 623 | + await updateLabelMultipleOption(option.id, form); | |
| 624 | + toast.success("Multiple option updated.", { | |
| 625 | + description: "The multiple option has been updated successfully.", | |
| 626 | + }); | |
| 627 | + onOpenChange(false); | |
| 628 | + onUpdated(); | |
| 629 | + } catch (e: any) { | |
| 630 | + toast.error("Failed to update multiple option.", { | |
| 631 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 632 | + }); | |
| 633 | + } finally { | |
| 634 | + setSubmitting(false); | |
| 635 | + } | |
| 636 | + }; | |
| 637 | + | |
| 638 | + return ( | |
| 639 | + <Dialog open={open} onOpenChange={onOpenChange}> | |
| 640 | + <DialogContent className="sm:max-w-[600px]"> | |
| 641 | + <DialogHeader> | |
| 642 | + <DialogTitle>Edit Multiple Option</DialogTitle> | |
| 643 | + <DialogDescription> | |
| 644 | + Update the multiple option details. | |
| 645 | + </DialogDescription> | |
| 646 | + </DialogHeader> | |
| 647 | + | |
| 648 | + <div className="grid gap-4 py-4"> | |
| 649 | + <div className="grid grid-cols-2 gap-4"> | |
| 650 | + <div className="space-y-2"> | |
| 651 | + <Label>Option Code *</Label> | |
| 652 | + <Input | |
| 653 | + placeholder="e.g. OPT_ALLERGENS" | |
| 654 | + value={form.optionCode} | |
| 655 | + onChange={(e) => setForm((p) => ({ ...p, optionCode: e.target.value }))} | |
| 656 | + /> | |
| 657 | + </div> | |
| 658 | + <div className="space-y-2"> | |
| 659 | + <Label>Option Name *</Label> | |
| 660 | + <Input | |
| 661 | + placeholder="e.g. Allergens" | |
| 662 | + value={form.optionName} | |
| 663 | + onChange={(e) => setForm((p) => ({ ...p, optionName: e.target.value }))} | |
| 664 | + /> | |
| 665 | + </div> | |
| 666 | + </div> | |
| 667 | + | |
| 668 | + <div className="space-y-2"> | |
| 669 | + <Label>Option Values *</Label> | |
| 670 | + <div className="flex gap-2"> | |
| 671 | + <Input | |
| 672 | + placeholder="Enter a value and press Add" | |
| 673 | + value={newValue} | |
| 674 | + onChange={(e) => setNewValue(e.target.value)} | |
| 675 | + onKeyDown={(e) => { | |
| 676 | + if (e.key === "Enter") { | |
| 677 | + e.preventDefault(); | |
| 678 | + addValue(); | |
| 679 | + } | |
| 680 | + }} | |
| 681 | + /> | |
| 682 | + <Button type="button" onClick={addValue} variant="outline"> | |
| 683 | + Add | |
| 684 | + </Button> | |
| 685 | + </div> | |
| 686 | + {form.optionValuesJson.length > 0 && ( | |
| 687 | + <div className="flex flex-wrap gap-2 mt-2"> | |
| 688 | + {form.optionValuesJson.map((val, idx) => ( | |
| 689 | + <Badge key={idx} variant="secondary" className="flex items-center gap-1"> | |
| 690 | + {val} | |
| 691 | + <button | |
| 692 | + type="button" | |
| 693 | + onClick={() => removeValue(idx)} | |
| 694 | + className="ml-1 hover:text-red-600" | |
| 695 | + > | |
| 696 | + <X className="h-3 w-3" /> | |
| 697 | + </button> | |
| 698 | + </Badge> | |
| 699 | + ))} | |
| 700 | + </div> | |
| 701 | + )} | |
| 702 | + </div> | |
| 703 | + | |
| 704 | + <div className="grid grid-cols-2 gap-4"> | |
| 705 | + <div className="space-y-2"> | |
| 706 | + <Label>Order</Label> | |
| 707 | + <Input | |
| 708 | + type="number" | |
| 709 | + placeholder="e.g. 1" | |
| 710 | + value={form.orderNum ?? ""} | |
| 711 | + onChange={(e) => setForm((p) => ({ ...p, orderNum: e.target.value ? Number(e.target.value) : null }))} | |
| 712 | + /> | |
| 713 | + </div> | |
| 714 | + <div className="flex items-center justify-between border border-gray-200 rounded-md px-3 bg-white" style={{ height: 40 }}> | |
| 715 | + <div className="text-sm font-medium text-gray-900">Enabled</div> | |
| 716 | + <Switch checked={form.state} onCheckedChange={(checked) => setForm((p) => ({ ...p, state: checked }))} /> | |
| 717 | + </div> | |
| 718 | + </div> | |
| 719 | + </div> | |
| 720 | + | |
| 721 | + <DialogFooter> | |
| 722 | + <Button variant="outline" onClick={() => onOpenChange(false)}> | |
| 723 | + Cancel | |
| 724 | + </Button> | |
| 725 | + <Button disabled={submitting} onClick={submit}> | |
| 726 | + {submitting ? "Updating..." : "Update"} | |
| 727 | + </Button> | |
| 728 | + </DialogFooter> | |
| 729 | + </DialogContent> | |
| 730 | + </Dialog> | |
| 731 | + ); | |
| 732 | +} | |
| 733 | + | |
| 734 | +function DeleteMultipleOptionDialog({ | |
| 735 | + open, | |
| 736 | + option, | |
| 737 | + onOpenChange, | |
| 738 | + onDeleted, | |
| 739 | +}: { | |
| 740 | + open: boolean; | |
| 741 | + option: LabelMultipleOptionDto | null; | |
| 742 | + onOpenChange: (open: boolean) => void; | |
| 743 | + onDeleted: () => void; | |
| 744 | +}) { | |
| 745 | + const [submitting, setSubmitting] = useState(false); | |
| 746 | + | |
| 747 | + const name = useMemo(() => { | |
| 748 | + const n = (option?.optionName ?? "").trim(); | |
| 749 | + return n || option?.optionCode || "this option"; | |
| 750 | + }, [option]); | |
| 751 | + | |
| 752 | + const submit = async () => { | |
| 753 | + if (!option?.id) return; | |
| 754 | + setSubmitting(true); | |
| 755 | + try { | |
| 756 | + await deleteLabelMultipleOption(option.id); | |
| 757 | + toast.success("Multiple option deleted.", { | |
| 758 | + description: "The multiple option has been removed successfully.", | |
| 759 | + }); | |
| 760 | + onOpenChange(false); | |
| 761 | + onDeleted(); | |
| 762 | + } catch (e: any) { | |
| 763 | + toast.error("Failed to delete multiple option.", { | |
| 764 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 765 | + }); | |
| 766 | + } finally { | |
| 767 | + setSubmitting(false); | |
| 768 | + } | |
| 769 | + }; | |
| 770 | + | |
| 771 | + return ( | |
| 772 | + <Dialog open={open} onOpenChange={onOpenChange}> | |
| 773 | + <DialogContent className="sm:max-w-none" style={{ width: "30%" }}> | |
| 774 | + <DialogHeader> | |
| 775 | + <DialogTitle>Delete Multiple Option</DialogTitle> | |
| 776 | + <DialogDescription>This action cannot be undone.</DialogDescription> | |
| 777 | + </DialogHeader> | |
| 778 | + | |
| 779 | + <div className="text-sm text-gray-700"> | |
| 780 | + Are you sure you want to delete <span className="font-medium">{name}</span>? | |
| 781 | + </div> | |
| 782 | + | |
| 783 | + <DialogFooter className="flex-row flex-wrap justify-end"> | |
| 784 | + <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}> | |
| 785 | + Cancel | |
| 786 | + </Button> | |
| 787 | + <Button | |
| 788 | + className="min-w-24" | |
| 789 | + variant="destructive" | |
| 790 | + disabled={submitting} | |
| 791 | + onClick={submit} | |
| 792 | + > | |
| 793 | + {submitting ? "Deleting..." : "Delete"} | |
| 794 | + </Button> | |
| 795 | + </DialogFooter> | |
| 796 | + </DialogContent> | |
| 797 | + </Dialog> | |
| 798 | + ); | |
| 799 | +} | ... | ... |
美国版/Food Labeling Management Platform/src/components/people/PeopleView.tsx
| ... | ... | @@ -614,7 +614,7 @@ export function PeopleView() { |
| 614 | 614 | </div> |
| 615 | 615 | ))} |
| 616 | 616 | {(!m.locations?.length && !(m.locationIds?.length ?? 0)) && ( |
| 617 | - <div className="text-xs text-gray-500">无</div> | |
| 617 | + <div className="text-xs text-gray-500">None</div> | |
| 618 | 618 | )} |
| 619 | 619 | </div> |
| 620 | 620 | </TableCell> |
| ... | ... | @@ -1489,7 +1489,7 @@ function MemberDialog({ |
| 1489 | 1489 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 1490 | 1490 | }, [open, member?.id]); |
| 1491 | 1491 | |
| 1492 | - // 与新增一致:只判断必填项是否有值,有值即可点保存;无值则提交时弹提示 | |
| 1492 | + // Same as create: Save enabled when required fields are filled; empty values show error on submit. | |
| 1493 | 1493 | const canSubmit = useMemo(() => { |
| 1494 | 1494 | if (!fullName.trim()) return false; |
| 1495 | 1495 | if (!userName.trim()) return false; | ... | ... |
美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx
| 1 | -import React, { useState } from 'react'; | |
| 2 | -import { | |
| 3 | - Search, | |
| 4 | - Plus, | |
| 5 | - Download, | |
| 6 | - Upload, | |
| 7 | - Edit, | |
| 8 | - Filter, | |
| 9 | - MoreHorizontal, | |
| 10 | - Check, | |
| 11 | - X, | |
| 12 | - Image as ImageIcon, | |
| 13 | - Type, | |
| 14 | - Palette, | |
| 15 | - Store, | |
| 16 | - Globe, | |
| 17 | - Barcode | |
| 18 | -} from 'lucide-react'; | |
| 1 | +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 2 | +import { Search, Plus, Download, Upload, Edit, MoreHorizontal, Image as ImageIcon, Package } from "lucide-react"; | |
| 19 | 3 | import { Button } from "../ui/button"; |
| 20 | 4 | import { Input } from "../ui/input"; |
| 21 | 5 | import { |
| ... | ... | @@ -33,7 +17,6 @@ import { |
| 33 | 17 | DialogFooter, |
| 34 | 18 | DialogHeader, |
| 35 | 19 | DialogTitle, |
| 36 | - DialogTrigger, | |
| 37 | 20 | } from "../ui/dialog"; |
| 38 | 21 | import { |
| 39 | 22 | Select, |
| ... | ... | @@ -42,160 +25,391 @@ import { |
| 42 | 25 | SelectTrigger, |
| 43 | 26 | SelectValue, |
| 44 | 27 | } from "../ui/select"; |
| 45 | -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; | |
| 46 | 28 | import { Label } from "../ui/label"; |
| 47 | 29 | import { Switch } from "../ui/switch"; |
| 48 | 30 | import { Badge } from "../ui/badge"; |
| 49 | -import { Checkbox } from "../ui/checkbox"; | |
| 50 | -import { ScrollArea } from "../ui/scroll-area"; | |
| 51 | - | |
| 52 | -// --- Mock Data --- | |
| 53 | - | |
| 54 | -const MOCK_LOCATIONS = [ | |
| 55 | - { id: '12345', name: 'Location 12345 - Downtown' }, | |
| 56 | - { id: '67890', name: 'Location 67890 - Uptown' }, | |
| 57 | - { id: '11111', name: 'Location 11111 - Airport' }, | |
| 58 | -]; | |
| 59 | - | |
| 60 | -const MOCK_CATEGORIES = [ | |
| 61 | - { id: 'cat1', name: 'Dairy', type: 'color', value: '#bfdbfe', status: 'active' }, | |
| 62 | - { id: 'cat2', name: 'Meat', type: 'image', value: 'meat.png', status: 'active' }, | |
| 63 | - { id: 'cat3', name: 'Bakery', type: 'text', value: 'Bakery', status: 'active' }, | |
| 64 | -]; | |
| 65 | - | |
| 66 | -const MOCK_PRODUCTS = [ | |
| 67 | - { | |
| 68 | - id: 'prod1', | |
| 69 | - locationId: '12345', | |
| 70 | - categoryId: 'cat1', | |
| 71 | - categoryName: 'Dairy', | |
| 72 | - name: 'Whole Milk', | |
| 73 | - productId: '2222222', | |
| 74 | - barcode: '123456789', | |
| 75 | - barcodeType: 'EAN-13', | |
| 76 | - status: 'active', | |
| 77 | - appearance: { type: 'text', value: 'Whole Milk' } | |
| 78 | - }, | |
| 79 | - { | |
| 80 | - id: 'prod2', | |
| 81 | - locationId: '12345', | |
| 82 | - categoryId: 'cat2', | |
| 83 | - categoryName: 'Meat', | |
| 84 | - name: 'Ground Beef', | |
| 85 | - productId: '3333333', | |
| 86 | - barcode: '113456789', | |
| 87 | - barcodeType: 'UPC-A', | |
| 88 | - status: 'active', | |
| 89 | - appearance: { type: 'color', value: '#ef4444' } | |
| 90 | - }, | |
| 91 | - { | |
| 92 | - id: 'prod3', | |
| 93 | - locationId: '67890', | |
| 94 | - categoryId: 'cat3', | |
| 95 | - categoryName: 'Bakery', | |
| 96 | - name: 'Croissant', | |
| 97 | - productId: '4444444', | |
| 98 | - barcode: '998877665', | |
| 99 | - barcodeType: 'EAN-8', | |
| 100 | - status: 'inactive', | |
| 101 | - appearance: { type: 'image', value: 'croissant.jpg' } | |
| 102 | - }, | |
| 103 | -]; | |
| 104 | - | |
| 105 | -// --- Components --- | |
| 31 | +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | |
| 32 | +import { toast } from "sonner"; | |
| 33 | +import { getLocations } from "../../services/locationService"; | |
| 34 | +import { getLabelCategories } from "../../services/labelCategoryService"; | |
| 35 | +import { | |
| 36 | + createProduct, | |
| 37 | + deleteProduct, | |
| 38 | + getProduct, | |
| 39 | + getProducts, | |
| 40 | + updateProduct, | |
| 41 | +} from "../../services/productService"; | |
| 42 | +import { | |
| 43 | + createProductLocation, | |
| 44 | + getProductIdsByLocation, | |
| 45 | + getProductLocations, | |
| 46 | + updateProductLocation, | |
| 47 | +} from "../../services/productLocationService"; | |
| 48 | +import type { LocationDto } from "../../types/location"; | |
| 49 | +import type { LabelCategoryDto } from "../../types/labelCategory"; | |
| 50 | +import type { ProductDto, ProductCreateInput, ProductUpdateInput } from "../../types/product"; | |
| 51 | +import { SearchableSelect } from "../ui/searchable-select"; | |
| 52 | + | |
| 53 | +function toDisplay(v: string | null | undefined): string { | |
| 54 | + const s = (v ?? "").trim(); | |
| 55 | + return s ? s : "—"; | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** productId -> locationIds(用于列表展示与编辑前绑定) */ | |
| 59 | +async function buildProductLocationMap(signal?: AbortSignal): Promise<Map<string, string[]>> { | |
| 60 | + const map = new Map<string, string[]>(); | |
| 61 | + try { | |
| 62 | + const res = await getProductLocations({ skipCount: 0, maxResultCount: 2000 }, signal); | |
| 63 | + for (const row of res.items ?? []) { | |
| 64 | + const pid = (row.productId ?? "").trim(); | |
| 65 | + const lid = (row.locationId ?? "").trim(); | |
| 66 | + if (!pid || !lid) continue; | |
| 67 | + if (!map.has(pid)) map.set(pid, []); | |
| 68 | + const arr = map.get(pid)!; | |
| 69 | + if (!arr.includes(lid)) arr.push(lid); | |
| 70 | + } | |
| 71 | + } catch { | |
| 72 | + /* 列表仍展示,门店列显示 — */ | |
| 73 | + } | |
| 74 | + return map; | |
| 75 | +} | |
| 76 | + | |
| 77 | +async function syncProductStoreBinding( | |
| 78 | + productId: string, | |
| 79 | + newLocationId: string, | |
| 80 | + previousLocationIds: string[], | |
| 81 | +): Promise<void> { | |
| 82 | + const prev = [...new Set(previousLocationIds.filter(Boolean))]; | |
| 83 | + for (const locId of prev) { | |
| 84 | + if (locId === newLocationId) continue; | |
| 85 | + const current = await getProductIdsByLocation(locId); | |
| 86 | + if (current.includes(productId)) { | |
| 87 | + await updateProductLocation(locId, { | |
| 88 | + productIds: current.filter((x) => x !== productId), | |
| 89 | + }); | |
| 90 | + } | |
| 91 | + } | |
| 92 | + if (newLocationId.trim()) { | |
| 93 | + await createProductLocation({ locationId: newLocationId, productIds: [productId] }); | |
| 94 | + } | |
| 95 | +} | |
| 106 | 96 | |
| 107 | 97 | export function ProductsView() { |
| 108 | - const [activeTab, setActiveTab] = useState('products'); | |
| 109 | - const [products, setProducts] = useState(MOCK_PRODUCTS); | |
| 110 | - const [categories, setCategories] = useState(MOCK_CATEGORIES); | |
| 111 | - | |
| 112 | - // Dialog States | |
| 98 | + const [activeTab, setActiveTab] = useState<"products" | "categories">("products"); | |
| 99 | + const [products, setProducts] = useState<ProductDto[]>([]); | |
| 100 | + const [total, setTotal] = useState(0); | |
| 101 | + const [loading, setLoading] = useState(false); | |
| 102 | + const [locationMap, setLocationMap] = useState<Map<string, string[]>>(new Map()); | |
| 103 | + const [locations, setLocations] = useState<LocationDto[]>([]); | |
| 104 | + const [labelCategories, setLabelCategories] = useState<LabelCategoryDto[]>([]); | |
| 105 | + | |
| 106 | + const [keyword, setKeyword] = useState(""); | |
| 107 | + const [debouncedKeyword, setDebouncedKeyword] = useState(""); | |
| 108 | + const keywordTimer = useRef<number | null>(null); | |
| 109 | + const [locationFilter, setLocationFilter] = useState("all"); | |
| 110 | + const [categoryFilter, setCategoryFilter] = useState("all"); | |
| 111 | + const [stateFilter, setStateFilter] = useState("all"); | |
| 112 | + const [pageIndex, setPageIndex] = useState(1); | |
| 113 | + const [pageSize, setPageSize] = useState(10); | |
| 114 | + const [refreshSeq, setRefreshSeq] = useState(0); | |
| 115 | + | |
| 116 | + const abortRef = useRef<AbortController | null>(null); | |
| 117 | + | |
| 113 | 118 | const [isProductDialogOpen, setIsProductDialogOpen] = useState(false); |
| 114 | 119 | const [isCategoryDialogOpen, setIsCategoryDialogOpen] = useState(false); |
| 120 | + const [editingProduct, setEditingProduct] = useState<ProductDto | null>(null); | |
| 121 | + const [deletingProduct, setDeletingProduct] = useState<ProductDto | null>(null); | |
| 122 | + const [actionsOpenId, setActionsOpenId] = useState<string | null>(null); | |
| 123 | + | |
| 124 | + useEffect(() => { | |
| 125 | + if (keywordTimer.current) window.clearTimeout(keywordTimer.current); | |
| 126 | + keywordTimer.current = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300); | |
| 127 | + return () => { | |
| 128 | + if (keywordTimer.current) window.clearTimeout(keywordTimer.current); | |
| 129 | + }; | |
| 130 | + }, [keyword]); | |
| 131 | + | |
| 132 | + useEffect(() => { | |
| 133 | + let c = false; | |
| 134 | + (async () => { | |
| 135 | + try { | |
| 136 | + const [locRes, catRes] = await Promise.all([ | |
| 137 | + getLocations({ skipCount: 0, maxResultCount: 500 }), | |
| 138 | + getLabelCategories({ skipCount: 0, maxResultCount: 500 }), | |
| 139 | + ]); | |
| 140 | + if (c) return; | |
| 141 | + setLocations(locRes.items ?? []); | |
| 142 | + setLabelCategories(catRes.items ?? []); | |
| 143 | + } catch { | |
| 144 | + if (!c) { | |
| 145 | + setLocations([]); | |
| 146 | + setLabelCategories([]); | |
| 147 | + } | |
| 148 | + } | |
| 149 | + })(); | |
| 150 | + return () => { | |
| 151 | + c = true; | |
| 152 | + }; | |
| 153 | + }, []); | |
| 154 | + | |
| 155 | + useEffect(() => { | |
| 156 | + setPageIndex(1); | |
| 157 | + }, [debouncedKeyword, locationFilter, categoryFilter, stateFilter, pageSize]); | |
| 158 | + | |
| 159 | + const needClientFilter = locationFilter !== "all" || categoryFilter !== "all"; | |
| 160 | + | |
| 161 | + useEffect(() => { | |
| 162 | + if (activeTab !== "products") return; | |
| 163 | + | |
| 164 | + const run = async () => { | |
| 165 | + abortRef.current?.abort(); | |
| 166 | + const ac = new AbortController(); | |
| 167 | + abortRef.current = ac; | |
| 168 | + | |
| 169 | + setLoading(true); | |
| 170 | + try { | |
| 171 | + const map = await buildProductLocationMap(ac.signal); | |
| 172 | + if (ac.signal.aborted) return; | |
| 173 | + setLocationMap(map); | |
| 174 | + | |
| 175 | + if (needClientFilter) { | |
| 176 | + const res = await getProducts( | |
| 177 | + { | |
| 178 | + skipCount: 0, | |
| 179 | + maxResultCount: 500, | |
| 180 | + keyword: debouncedKeyword || undefined, | |
| 181 | + state: stateFilter === "all" ? undefined : stateFilter === "true", | |
| 182 | + }, | |
| 183 | + ac.signal, | |
| 184 | + ); | |
| 185 | + if (ac.signal.aborted) return; | |
| 186 | + let list = res.items ?? []; | |
| 187 | + if (locationFilter !== "all") { | |
| 188 | + const allowed = new Set(await getProductIdsByLocation(locationFilter)); | |
| 189 | + list = list.filter((p) => allowed.has(p.id)); | |
| 190 | + } | |
| 191 | + if (categoryFilter !== "all") { | |
| 192 | + list = list.filter((p) => (p.categoryName ?? "").trim() === categoryFilter); | |
| 193 | + } | |
| 194 | + const t = list.length; | |
| 195 | + setTotal(t); | |
| 196 | + const start = (pageIndex - 1) * pageSize; | |
| 197 | + setProducts(list.slice(start, start + pageSize)); | |
| 198 | + } else { | |
| 199 | + const skip = (pageIndex - 1) * pageSize; | |
| 200 | + const res = await getProducts( | |
| 201 | + { | |
| 202 | + skipCount: skip, | |
| 203 | + maxResultCount: pageSize, | |
| 204 | + keyword: debouncedKeyword || undefined, | |
| 205 | + state: stateFilter === "all" ? undefined : stateFilter === "true", | |
| 206 | + }, | |
| 207 | + ac.signal, | |
| 208 | + ); | |
| 209 | + if (ac.signal.aborted) return; | |
| 210 | + setProducts(res.items ?? []); | |
| 211 | + setTotal(res.totalCount ?? 0); | |
| 212 | + } | |
| 213 | + } catch (e: any) { | |
| 214 | + if (e?.name === "AbortError") return; | |
| 215 | + toast.error("Failed to load products", { | |
| 216 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 217 | + }); | |
| 218 | + setProducts([]); | |
| 219 | + setTotal(0); | |
| 220 | + } finally { | |
| 221 | + if (!ac.signal.aborted) setLoading(false); | |
| 222 | + } | |
| 223 | + }; | |
| 224 | + | |
| 225 | + run(); | |
| 226 | + return () => abortRef.current?.abort(); | |
| 227 | + }, [ | |
| 228 | + activeTab, | |
| 229 | + debouncedKeyword, | |
| 230 | + locationFilter, | |
| 231 | + categoryFilter, | |
| 232 | + stateFilter, | |
| 233 | + pageIndex, | |
| 234 | + pageSize, | |
| 235 | + refreshSeq, | |
| 236 | + needClientFilter, | |
| 237 | + ]); | |
| 238 | + | |
| 239 | + const refresh = () => setRefreshSeq((x) => x + 1); | |
| 240 | + | |
| 241 | + const locationOptions = useMemo( | |
| 242 | + () => | |
| 243 | + locations.map((loc) => ({ | |
| 244 | + value: loc.id, | |
| 245 | + label: toDisplay(loc.locationName ?? loc.locationCode ?? loc.id), | |
| 246 | + })), | |
| 247 | + [locations], | |
| 248 | + ); | |
| 249 | + | |
| 250 | + const categoryNameOptions = useMemo( | |
| 251 | + () => | |
| 252 | + labelCategories.map((c) => ({ | |
| 253 | + value: (c.categoryName ?? c.categoryCode ?? c.id ?? "").trim(), | |
| 254 | + label: toDisplay(c.categoryName ?? c.categoryCode ?? c.id), | |
| 255 | + })).filter((o) => o.value), | |
| 256 | + [labelCategories], | |
| 257 | + ); | |
| 258 | + | |
| 259 | + const totalPages = Math.max(1, Math.ceil(total / pageSize)); | |
| 260 | + | |
| 261 | + const locationLabel = useCallback( | |
| 262 | + (id: string) => { | |
| 263 | + const loc = locations.find((l) => l.id === id); | |
| 264 | + return toDisplay(loc?.locationName ?? loc?.locationCode ?? id); | |
| 265 | + }, | |
| 266 | + [locations], | |
| 267 | + ); | |
| 115 | 268 | |
| 116 | 269 | return ( |
| 117 | 270 | <div className="h-full flex flex-col"> |
| 118 | - {/* Toolbar + Filters - one row, style consistent with Labels / Location Manager */} | |
| 119 | 271 | <div className="pb-4"> |
| 120 | 272 | <div className="flex flex-nowrap items-center gap-3 flex-wrap"> |
| 121 | - <div className="flex items-center w-40 shrink-0 rounded-md border border-gray-300 bg-white overflow-hidden" style={{ height: 40 }}> | |
| 273 | + <div | |
| 274 | + className="flex items-center w-40 shrink-0 rounded-md border border-gray-300 bg-white overflow-hidden" | |
| 275 | + style={{ height: 40 }} | |
| 276 | + > | |
| 122 | 277 | <Search className="h-4 w-4 text-gray-400 shrink-0 ml-2.5 pointer-events-none" /> |
| 123 | 278 | <Input |
| 124 | 279 | placeholder="Search..." |
| 280 | + value={keyword} | |
| 281 | + onChange={(e) => setKeyword(e.target.value)} | |
| 125 | 282 | className="flex-1 min-w-0 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 py-2 px-2 h-full placeholder:text-gray-500" |
| 126 | 283 | /> |
| 127 | 284 | </div> |
| 128 | - <Select defaultValue="partner-a"> | |
| 129 | - <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' }}> | |
| 285 | + <Select value="all" disabled> | |
| 286 | + <SelectTrigger | |
| 287 | + className="w-[140px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0 opacity-70" | |
| 288 | + style={{ height: 40, boxSizing: "border-box" }} | |
| 289 | + > | |
| 130 | 290 | <SelectValue placeholder="Partner" /> |
| 131 | 291 | </SelectTrigger> |
| 132 | 292 | <SelectContent> |
| 133 | - <SelectItem value="partner-a">Partner A</SelectItem> | |
| 293 | + <SelectItem value="all">All partners</SelectItem> | |
| 134 | 294 | </SelectContent> |
| 135 | 295 | </Select> |
| 136 | - <Select defaultValue="group-b"> | |
| 137 | - <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' }}> | |
| 296 | + <Select value="all" disabled> | |
| 297 | + <SelectTrigger | |
| 298 | + className="w-[140px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0 opacity-70" | |
| 299 | + style={{ height: 40, boxSizing: "border-box" }} | |
| 300 | + > | |
| 138 | 301 | <SelectValue placeholder="Group" /> |
| 139 | 302 | </SelectTrigger> |
| 140 | 303 | <SelectContent> |
| 141 | - <SelectItem value="group-b">Group B</SelectItem> | |
| 304 | + <SelectItem value="all">All groups</SelectItem> | |
| 142 | 305 | </SelectContent> |
| 143 | 306 | </Select> |
| 144 | - <Select defaultValue="loc-12345"> | |
| 145 | - <SelectTrigger className="w-[160px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0" style={{ height: 40, boxSizing: 'border-box' }}> | |
| 307 | + <Select value={locationFilter} onValueChange={setLocationFilter}> | |
| 308 | + <SelectTrigger | |
| 309 | + className="w-[160px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0" | |
| 310 | + style={{ height: 40, boxSizing: "border-box" }} | |
| 311 | + > | |
| 146 | 312 | <SelectValue placeholder="Location" /> |
| 147 | 313 | </SelectTrigger> |
| 148 | 314 | <SelectContent> |
| 149 | - <SelectItem value="loc-12345">Location 12345</SelectItem> | |
| 150 | 315 | <SelectItem value="all">All Locations</SelectItem> |
| 316 | + {locations.map((loc) => ( | |
| 317 | + <SelectItem key={loc.id} value={loc.id}> | |
| 318 | + {toDisplay(loc.locationName ?? loc.locationCode ?? loc.id)} | |
| 319 | + </SelectItem> | |
| 320 | + ))} | |
| 151 | 321 | </SelectContent> |
| 152 | 322 | </Select> |
| 153 | - <Select> | |
| 154 | - <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' }}> | |
| 323 | + <Select value={categoryFilter} onValueChange={setCategoryFilter}> | |
| 324 | + <SelectTrigger | |
| 325 | + className="w-[140px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0" | |
| 326 | + style={{ height: 40, boxSizing: "border-box" }} | |
| 327 | + > | |
| 155 | 328 | <SelectValue placeholder="Category" /> |
| 156 | 329 | </SelectTrigger> |
| 157 | 330 | <SelectContent> |
| 158 | 331 | <SelectItem value="all">All Categories</SelectItem> |
| 159 | - {categories.map(c => ( | |
| 160 | - <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem> | |
| 332 | + {categoryNameOptions.map((o) => ( | |
| 333 | + <SelectItem key={o.value} value={o.value}> | |
| 334 | + {o.label} | |
| 335 | + </SelectItem> | |
| 161 | 336 | ))} |
| 162 | 337 | </SelectContent> |
| 163 | 338 | </Select> |
| 339 | + <Select value={stateFilter} onValueChange={setStateFilter}> | |
| 340 | + <SelectTrigger | |
| 341 | + className="w-[120px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0" | |
| 342 | + style={{ height: 40, boxSizing: "border-box" }} | |
| 343 | + > | |
| 344 | + <SelectValue placeholder="State" /> | |
| 345 | + </SelectTrigger> | |
| 346 | + <SelectContent> | |
| 347 | + <SelectItem value="all">All states</SelectItem> | |
| 348 | + <SelectItem value="true">Active</SelectItem> | |
| 349 | + <SelectItem value="false">Inactive</SelectItem> | |
| 350 | + </SelectContent> | |
| 351 | + </Select> | |
| 164 | 352 | <div className="flex-1 min-w-2" /> |
| 165 | - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0"> | |
| 353 | + <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" disabled> | |
| 166 | 354 | <Upload className="w-4 h-4" /> Bulk Import |
| 167 | 355 | </Button> |
| 168 | - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0"> | |
| 356 | + <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" disabled> | |
| 169 | 357 | <Download className="w-4 h-4" /> Bulk Export |
| 170 | 358 | </Button> |
| 171 | - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0"> | |
| 359 | + <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" disabled> | |
| 172 | 360 | <Edit className="w-4 h-4" /> Bulk Edit |
| 173 | 361 | </Button> |
| 174 | - {activeTab === 'products' ? ( | |
| 175 | - <Button className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0" onClick={() => setIsProductDialogOpen(true)}> | |
| 362 | + {activeTab === "products" ? ( | |
| 363 | + <Button | |
| 364 | + className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0" | |
| 365 | + onClick={() => { | |
| 366 | + setEditingProduct(null); | |
| 367 | + setIsProductDialogOpen(true); | |
| 368 | + }} | |
| 369 | + > | |
| 176 | 370 | New Product <Plus className="w-4 h-4" /> |
| 177 | 371 | </Button> |
| 178 | 372 | ) : ( |
| 179 | - <Button className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0" onClick={() => setIsCategoryDialogOpen(true)}> | |
| 373 | + <Button | |
| 374 | + className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0" | |
| 375 | + onClick={() => setIsCategoryDialogOpen(true)} | |
| 376 | + > | |
| 180 | 377 | New Category <Plus className="w-4 h-4" /> |
| 181 | 378 | </Button> |
| 182 | 379 | )} |
| 183 | 380 | </div> |
| 184 | 381 | |
| 185 | - {/* Tabs - underline spans full width */} | |
| 186 | 382 | <div className="w-full border-b border-gray-200 mt-4"> |
| 187 | 383 | <div className="flex overflow-x-auto w-fit"> |
| 188 | 384 | <button |
| 189 | - onClick={() => setActiveTab('products')} | |
| 190 | - style={activeTab === 'products' ? { borderBottomWidth: 2, borderBottomStyle: 'solid', borderBottomColor: '#2563eb' } : undefined} | |
| 191 | - className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap cursor-pointer transition-colors -mb-px border-b-2 ${activeTab === 'products' ? 'text-blue-600' : 'border-b-transparent text-gray-600 hover:text-gray-800'}`} | |
| 385 | + type="button" | |
| 386 | + onClick={() => setActiveTab("products")} | |
| 387 | + style={ | |
| 388 | + activeTab === "products" | |
| 389 | + ? { borderBottomWidth: 2, borderBottomStyle: "solid", borderBottomColor: "#2563eb" } | |
| 390 | + : undefined | |
| 391 | + } | |
| 392 | + className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap cursor-pointer transition-colors -mb-px border-b-2 ${ | |
| 393 | + activeTab === "products" | |
| 394 | + ? "text-blue-600" | |
| 395 | + : "border-b-transparent text-gray-600 hover:text-gray-800" | |
| 396 | + }`} | |
| 192 | 397 | > |
| 193 | 398 | Products |
| 194 | 399 | </button> |
| 195 | 400 | <button |
| 196 | - onClick={() => setActiveTab('categories')} | |
| 197 | - style={activeTab === 'categories' ? { borderBottomWidth: 2, borderBottomStyle: 'solid', borderBottomColor: '#2563eb' } : undefined} | |
| 198 | - className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap cursor-pointer transition-colors -mb-px border-b-2 ${activeTab === 'categories' ? 'text-blue-600' : 'border-b-transparent text-gray-600 hover:text-gray-800'}`} | |
| 401 | + type="button" | |
| 402 | + onClick={() => setActiveTab("categories")} | |
| 403 | + style={ | |
| 404 | + activeTab === "categories" | |
| 405 | + ? { borderBottomWidth: 2, borderBottomStyle: "solid", borderBottomColor: "#2563eb" } | |
| 406 | + : undefined | |
| 407 | + } | |
| 408 | + className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap cursor-pointer transition-colors -mb-px border-b-2 ${ | |
| 409 | + activeTab === "categories" | |
| 410 | + ? "text-blue-600" | |
| 411 | + : "border-b-transparent text-gray-600 hover:text-gray-800" | |
| 412 | + }`} | |
| 199 | 413 | > |
| 200 | 414 | Categories |
| 201 | 415 | </button> |
| ... | ... | @@ -203,366 +417,480 @@ export function ProductsView() { |
| 203 | 417 | </div> |
| 204 | 418 | </div> |
| 205 | 419 | |
| 206 | - {/* Content Area - same padding as Labels */} | |
| 207 | 420 | <div className="flex-1 overflow-auto pt-6"> |
| 208 | - {activeTab === 'products' ? ( | |
| 421 | + {activeTab === "products" ? ( | |
| 209 | 422 | <div className="bg-white border border-gray-200 shadow-sm rounded-md overflow-hidden"> |
| 210 | - <Table> | |
| 423 | + <Table> | |
| 211 | 424 | <TableHeader> |
| 212 | 425 | <TableRow className="bg-gray-100 hover:bg-gray-100"> |
| 213 | - <TableHead className="text-gray-900 font-bold border-r">Location ID</TableHead> | |
| 214 | - <TableHead className="text-gray-900 font-bold border-r">Product Category</TableHead> | |
| 215 | - <TableHead className="text-gray-900 font-bold border-r">Product</TableHead> | |
| 216 | - <TableHead className="text-gray-900 font-bold border-r">Product ID</TableHead> | |
| 217 | - <TableHead className="text-gray-900 font-bold border-r">Product Barcode</TableHead> | |
| 218 | - <TableHead className="text-gray-900 font-bold border-r">Status</TableHead> | |
| 219 | - <TableHead className="text-gray-900 font-bold text-center">Actions</TableHead> | |
| 426 | + <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Location</TableHead> | |
| 427 | + <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Product Category</TableHead> | |
| 428 | + <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Product</TableHead> | |
| 429 | + <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Product ID</TableHead> | |
| 430 | + <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Product Code</TableHead> | |
| 431 | + <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Status</TableHead> | |
| 432 | + <TableHead className="text-gray-900 font-bold text-center whitespace-nowrap">Actions</TableHead> | |
| 220 | 433 | </TableRow> |
| 221 | 434 | </TableHeader> |
| 222 | 435 | <TableBody> |
| 223 | - {products.map((product) => ( | |
| 224 | - <TableRow key={product.id}> | |
| 225 | - <TableCell className="border-r font-numeric">{product.locationId}</TableCell> | |
| 226 | - <TableCell className="border-r text-gray-900 font-medium">{product.categoryName}</TableCell> | |
| 227 | - <TableCell className="border-r text-gray-900 font-medium"> | |
| 228 | - <div className="flex items-center gap-2"> | |
| 229 | - {product.appearance.type === 'color' && ( | |
| 230 | - <div className="w-4 h-4 rounded-full border border-gray-300 shadow-sm" style={{ backgroundColor: product.appearance.value }} /> | |
| 231 | - )} | |
| 232 | - {product.appearance.type === 'image' && ( | |
| 233 | - <ImageIcon className="w-4 h-4 text-gray-500" /> | |
| 234 | - )} | |
| 235 | - {product.name} | |
| 236 | - </div> | |
| 237 | - </TableCell> | |
| 238 | - <TableCell className="border-r font-numeric text-gray-600">{product.productId}</TableCell> | |
| 239 | - <TableCell className="border-r font-numeric"> | |
| 240 | - <div className="flex flex-col"> | |
| 241 | - <span className="text-xs text-gray-400">{product.barcodeType}</span> | |
| 242 | - <span>{product.barcode}</span> | |
| 243 | - </div> | |
| 244 | - </TableCell> | |
| 245 | - <TableCell className="border-r"> | |
| 246 | - <Badge variant={product.status === 'active' ? 'default' : 'secondary'} className={product.status === 'active' ? "bg-green-600" : "bg-gray-400"}> | |
| 247 | - {product.status} | |
| 248 | - </Badge> | |
| 249 | - </TableCell> | |
| 250 | - <TableCell className="text-center"> | |
| 251 | - <Button variant="ghost" size="icon" className="h-8 w-8"> | |
| 252 | - <MoreHorizontal className="h-4 w-4" /> | |
| 253 | - </Button> | |
| 436 | + {loading ? ( | |
| 437 | + <TableRow> | |
| 438 | + <TableCell colSpan={7} className="text-center text-gray-500 py-10"> | |
| 439 | + Loading... | |
| 254 | 440 | </TableCell> |
| 255 | 441 | </TableRow> |
| 256 | - ))} | |
| 257 | - </TableBody> | |
| 258 | - </Table> | |
| 259 | - </div> | |
| 260 | - ) : ( | |
| 261 | - <div className="bg-white border border-gray-200 shadow-sm rounded-md overflow-hidden"> | |
| 262 | - <Table> | |
| 263 | - <TableHeader> | |
| 264 | - <TableRow className="bg-gray-100 hover:bg-gray-100"> | |
| 265 | - <TableHead className="text-gray-900 font-bold border-r">Category Name</TableHead> | |
| 266 | - <TableHead className="text-gray-900 font-bold border-r">Display Type</TableHead> | |
| 267 | - <TableHead className="text-gray-900 font-bold border-r">Preview</TableHead> | |
| 268 | - <TableHead className="text-gray-900 font-bold border-r">Status</TableHead> | |
| 269 | - <TableHead className="text-gray-900 font-bold text-center">Actions</TableHead> | |
| 270 | - </TableRow> | |
| 271 | - </TableHeader> | |
| 272 | - <TableBody> | |
| 273 | - {categories.map((category) => ( | |
| 274 | - <TableRow key={category.id}> | |
| 275 | - <TableCell className="border-r font-medium">{category.name}</TableCell> | |
| 276 | - <TableCell className="border-r capitalize">{category.type}</TableCell> | |
| 277 | - <TableCell className="border-r"> | |
| 278 | - {category.type === 'color' && ( | |
| 279 | - <div className="w-8 h-8 rounded-md border border-gray-200 shadow-sm" style={{ backgroundColor: category.value }} /> | |
| 280 | - )} | |
| 281 | - {category.type === 'image' && ( | |
| 282 | - <div className="w-8 h-8 bg-gray-100 flex items-center justify-center rounded-md border border-gray-200"> | |
| 283 | - <ImageIcon className="w-4 h-4 text-gray-500" /> | |
| 284 | - </div> | |
| 285 | - )} | |
| 286 | - {category.type === 'text' && ( | |
| 287 | - <div className="w-auto px-2 py-1 bg-gray-100 rounded-md border border-gray-200 text-xs font-medium inline-block"> | |
| 288 | - {category.value} | |
| 289 | - </div> | |
| 290 | - )} | |
| 291 | - </TableCell> | |
| 292 | - <TableCell className="border-r"> | |
| 293 | - <Badge variant={category.status === 'active' ? 'default' : 'secondary'} className={category.status === 'active' ? "bg-green-600" : "bg-gray-400"}> | |
| 294 | - {category.status} | |
| 295 | - </Badge> | |
| 296 | - </TableCell> | |
| 297 | - <TableCell className="text-center"> | |
| 298 | - <Button variant="ghost" size="icon" className="h-8 w-8"> | |
| 299 | - <Edit className="h-4 w-4" /> | |
| 300 | - </Button> | |
| 442 | + ) : products.length === 0 ? ( | |
| 443 | + <TableRow> | |
| 444 | + <TableCell colSpan={7} className="text-center text-gray-500 py-10"> | |
| 445 | + No products found. | |
| 301 | 446 | </TableCell> |
| 302 | 447 | </TableRow> |
| 303 | - ))} | |
| 448 | + ) : ( | |
| 449 | + products.map((p) => { | |
| 450 | + const locIds = locationMap.get(p.id) ?? []; | |
| 451 | + const locText = | |
| 452 | + locIds.length === 0 | |
| 453 | + ? "—" | |
| 454 | + : locIds.map((id) => locationLabel(id)).join(", "); | |
| 455 | + const active = p.state !== false; | |
| 456 | + return ( | |
| 457 | + <TableRow key={p.id}> | |
| 458 | + <TableCell className="border-r text-sm max-w-[200px] truncate" title={locText}> | |
| 459 | + {locText} | |
| 460 | + </TableCell> | |
| 461 | + <TableCell className="border-r text-gray-900 font-medium whitespace-nowrap"> | |
| 462 | + {toDisplay(p.categoryName)} | |
| 463 | + </TableCell> | |
| 464 | + <TableCell className="border-r text-gray-900 font-medium"> | |
| 465 | + <div className="flex items-center gap-2 min-w-0"> | |
| 466 | + {p.productImageUrl ? ( | |
| 467 | + <img | |
| 468 | + src={p.productImageUrl} | |
| 469 | + alt="" | |
| 470 | + className="w-8 h-8 rounded object-cover border border-gray-200 shrink-0" | |
| 471 | + /> | |
| 472 | + ) : ( | |
| 473 | + <Package className="w-4 h-4 text-gray-400 shrink-0" /> | |
| 474 | + )} | |
| 475 | + <span className="truncate">{toDisplay(p.productName)}</span> | |
| 476 | + </div> | |
| 477 | + </TableCell> | |
| 478 | + <TableCell className="border-r font-mono text-sm text-gray-600 whitespace-nowrap"> | |
| 479 | + {p.id} | |
| 480 | + </TableCell> | |
| 481 | + <TableCell className="border-r font-mono text-sm text-gray-600 whitespace-nowrap"> | |
| 482 | + {toDisplay(p.productCode)} | |
| 483 | + </TableCell> | |
| 484 | + <TableCell className="border-r whitespace-nowrap"> | |
| 485 | + <Badge | |
| 486 | + variant={active ? "default" : "secondary"} | |
| 487 | + className={active ? "bg-green-600" : "bg-gray-400"} | |
| 488 | + > | |
| 489 | + {active ? "active" : "inactive"} | |
| 490 | + </Badge> | |
| 491 | + </TableCell> | |
| 492 | + <TableCell className="text-center whitespace-nowrap"> | |
| 493 | + <Popover | |
| 494 | + open={actionsOpenId === p.id} | |
| 495 | + onOpenChange={(open) => setActionsOpenId(open ? p.id : null)} | |
| 496 | + > | |
| 497 | + <PopoverTrigger asChild> | |
| 498 | + <Button type="button" variant="ghost" size="icon" className="h-8 w-8"> | |
| 499 | + <MoreHorizontal className="h-4 w-4" /> | |
| 500 | + </Button> | |
| 501 | + </PopoverTrigger> | |
| 502 | + <PopoverContent align="end" className="w-36 p-1"> | |
| 503 | + <Button | |
| 504 | + type="button" | |
| 505 | + variant="ghost" | |
| 506 | + className="w-full justify-start h-9 px-2 font-normal" | |
| 507 | + onClick={async () => { | |
| 508 | + setActionsOpenId(null); | |
| 509 | + try { | |
| 510 | + const fresh = await getProduct(p.id); | |
| 511 | + setEditingProduct(fresh); | |
| 512 | + setIsProductDialogOpen(true); | |
| 513 | + } catch (e: any) { | |
| 514 | + toast.error("Failed to load product", { | |
| 515 | + description: e?.message ? String(e.message) : "", | |
| 516 | + }); | |
| 517 | + } | |
| 518 | + }} | |
| 519 | + > | |
| 520 | + <Edit className="w-4 h-4 mr-2" /> | |
| 521 | + Edit | |
| 522 | + </Button> | |
| 523 | + <Button | |
| 524 | + type="button" | |
| 525 | + variant="ghost" | |
| 526 | + className="w-full justify-start h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50" | |
| 527 | + onClick={() => { | |
| 528 | + setActionsOpenId(null); | |
| 529 | + setDeletingProduct(p); | |
| 530 | + }} | |
| 531 | + > | |
| 532 | + Delete | |
| 533 | + </Button> | |
| 534 | + </PopoverContent> | |
| 535 | + </Popover> | |
| 536 | + </TableCell> | |
| 537 | + </TableRow> | |
| 538 | + ); | |
| 539 | + }) | |
| 540 | + )} | |
| 304 | 541 | </TableBody> |
| 305 | 542 | </Table> |
| 543 | + <div className="flex items-center justify-between px-3 py-2 text-sm text-gray-600 border-t border-gray-100"> | |
| 544 | + <span> | |
| 545 | + Showing {total === 0 ? 0 : (pageIndex - 1) * pageSize + 1}- | |
| 546 | + {Math.min(pageIndex * pageSize, total)} of {total} | |
| 547 | + </span> | |
| 548 | + <div className="flex items-center gap-2"> | |
| 549 | + <Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}> | |
| 550 | + <SelectTrigger className="w-[100px] h-9"> | |
| 551 | + <SelectValue /> | |
| 552 | + </SelectTrigger> | |
| 553 | + <SelectContent> | |
| 554 | + {[10, 20, 50].map((n) => ( | |
| 555 | + <SelectItem key={n} value={String(n)}> | |
| 556 | + {n} / page | |
| 557 | + </SelectItem> | |
| 558 | + ))} | |
| 559 | + </SelectContent> | |
| 560 | + </Select> | |
| 561 | + <Button | |
| 562 | + type="button" | |
| 563 | + variant="outline" | |
| 564 | + size="sm" | |
| 565 | + disabled={pageIndex <= 1} | |
| 566 | + onClick={() => setPageIndex((x) => Math.max(1, x - 1))} | |
| 567 | + > | |
| 568 | + Prev | |
| 569 | + </Button> | |
| 570 | + <span className="text-xs tabular-nums"> | |
| 571 | + Page {pageIndex} / {totalPages} | |
| 572 | + </span> | |
| 573 | + <Button | |
| 574 | + type="button" | |
| 575 | + variant="outline" | |
| 576 | + size="sm" | |
| 577 | + disabled={pageIndex >= totalPages} | |
| 578 | + onClick={() => setPageIndex((x) => Math.min(totalPages, x + 1))} | |
| 579 | + > | |
| 580 | + Next | |
| 581 | + </Button> | |
| 582 | + </div> | |
| 583 | + </div> | |
| 306 | 584 | </div> |
| 585 | + ) : ( | |
| 586 | + <CategoriesPlaceholderTab categories={labelCategories} /> | |
| 307 | 587 | )} |
| 308 | 588 | </div> |
| 309 | 589 | |
| 310 | - {/* --- Modals --- */} | |
| 311 | - <CreateProductDialog | |
| 312 | - open={isProductDialogOpen} | |
| 313 | - onOpenChange={setIsProductDialogOpen} | |
| 314 | - categories={categories} | |
| 590 | + <ProductFormDialog | |
| 591 | + open={isProductDialogOpen} | |
| 592 | + onOpenChange={(o) => { | |
| 593 | + setIsProductDialogOpen(o); | |
| 594 | + if (!o) setEditingProduct(null); | |
| 595 | + }} | |
| 596 | + editing={editingProduct} | |
| 597 | + locationOptions={locationOptions} | |
| 598 | + categoryOptions={categoryNameOptions} | |
| 599 | + locationMap={locationMap} | |
| 600 | + onSaved={() => { | |
| 601 | + refresh(); | |
| 602 | + setIsProductDialogOpen(false); | |
| 603 | + setEditingProduct(null); | |
| 604 | + }} | |
| 315 | 605 | /> |
| 316 | - | |
| 317 | - <CreateCategoryDialog | |
| 318 | - open={isCategoryDialogOpen} | |
| 319 | - onOpenChange={setIsCategoryDialogOpen} | |
| 606 | + | |
| 607 | + <DeleteProductDialog | |
| 608 | + open={!!deletingProduct} | |
| 609 | + product={deletingProduct} | |
| 610 | + onOpenChange={(o) => { | |
| 611 | + if (!o) setDeletingProduct(null); | |
| 612 | + }} | |
| 613 | + onDeleted={refresh} | |
| 320 | 614 | /> |
| 615 | + | |
| 616 | + <CreateCategoryPlaceholderDialog open={isCategoryDialogOpen} onOpenChange={setIsCategoryDialogOpen} /> | |
| 617 | + </div> | |
| 618 | + ); | |
| 619 | +} | |
| 620 | + | |
| 621 | +function CategoriesPlaceholderTab({ categories }: { categories: LabelCategoryDto[] }) { | |
| 622 | + return ( | |
| 623 | + <div className="bg-white border border-gray-200 shadow-sm rounded-md overflow-hidden"> | |
| 624 | + <Table> | |
| 625 | + <TableHeader> | |
| 626 | + <TableRow className="bg-gray-100 hover:bg-gray-100"> | |
| 627 | + <TableHead className="text-gray-900 font-bold border-r">Category Name</TableHead> | |
| 628 | + <TableHead className="text-gray-900 font-bold border-r">Code</TableHead> | |
| 629 | + <TableHead className="text-gray-900 font-bold border-r">Status</TableHead> | |
| 630 | + </TableRow> | |
| 631 | + </TableHeader> | |
| 632 | + <TableBody> | |
| 633 | + {categories.length === 0 ? ( | |
| 634 | + <TableRow> | |
| 635 | + <TableCell colSpan={3} className="text-center text-gray-500 py-8"> | |
| 636 | + No categories loaded. Use Labeling → Label Categories to manage. | |
| 637 | + </TableCell> | |
| 638 | + </TableRow> | |
| 639 | + ) : ( | |
| 640 | + categories.map((c) => ( | |
| 641 | + <TableRow key={c.id}> | |
| 642 | + <TableCell className="border-r font-medium">{toDisplay(c.categoryName)}</TableCell> | |
| 643 | + <TableCell className="border-r text-gray-600">{toDisplay(c.categoryCode)}</TableCell> | |
| 644 | + <TableCell className="border-r"> | |
| 645 | + <Badge variant={c.state !== false ? "default" : "secondary"}> | |
| 646 | + {c.state !== false ? "active" : "inactive"} | |
| 647 | + </Badge> | |
| 648 | + </TableCell> | |
| 649 | + </TableRow> | |
| 650 | + )) | |
| 651 | + )} | |
| 652 | + </TableBody> | |
| 653 | + </Table> | |
| 321 | 654 | </div> |
| 322 | 655 | ); |
| 323 | 656 | } |
| 324 | 657 | |
| 325 | -// --- Sub-components for Dialogs --- | |
| 658 | +function ProductFormDialog({ | |
| 659 | + open, | |
| 660 | + onOpenChange, | |
| 661 | + editing, | |
| 662 | + locationOptions, | |
| 663 | + categoryOptions, | |
| 664 | + locationMap, | |
| 665 | + onSaved, | |
| 666 | +}: { | |
| 667 | + open: boolean; | |
| 668 | + onOpenChange: (o: boolean) => void; | |
| 669 | + editing: ProductDto | null; | |
| 670 | + locationOptions: { value: string; label: string }[]; | |
| 671 | + categoryOptions: { value: string; label: string }[]; | |
| 672 | + locationMap: Map<string, string[]>; | |
| 673 | + onSaved: () => void; | |
| 674 | +}) { | |
| 675 | + const [submitting, setSubmitting] = useState(false); | |
| 676 | + const [productCode, setProductCode] = useState(""); | |
| 677 | + const [productName, setProductName] = useState(""); | |
| 678 | + const [categoryName, setCategoryName] = useState(""); | |
| 679 | + const [productImageUrl, setProductImageUrl] = useState(""); | |
| 680 | + const [state, setState] = useState(true); | |
| 681 | + const [locationId, setLocationId] = useState(""); | |
| 682 | + | |
| 683 | + useEffect(() => { | |
| 684 | + if (!open) return; | |
| 685 | + if (editing) { | |
| 686 | + setProductCode(editing.productCode ?? ""); | |
| 687 | + setProductName(editing.productName ?? ""); | |
| 688 | + setCategoryName((editing.categoryName ?? "").trim()); | |
| 689 | + setProductImageUrl(editing.productImageUrl ?? ""); | |
| 690 | + setState(editing.state !== false); | |
| 691 | + const lids = locationMap.get(editing.id) ?? []; | |
| 692 | + setLocationId(lids[0] ?? ""); | |
| 693 | + } else { | |
| 694 | + setProductCode(""); | |
| 695 | + setProductName(""); | |
| 696 | + setCategoryName(""); | |
| 697 | + setProductImageUrl(""); | |
| 698 | + setState(true); | |
| 699 | + setLocationId(""); | |
| 700 | + } | |
| 701 | + }, [open, editing, locationMap]); | |
| 702 | + | |
| 703 | + const submit = async () => { | |
| 704 | + if (!productCode.trim() || !productName.trim()) { | |
| 705 | + toast.error("Validation", { description: "Product code and name are required." }); | |
| 706 | + return; | |
| 707 | + } | |
| 708 | + if (!locationId.trim()) { | |
| 709 | + toast.error("Validation", { description: "Select a store to bind this product." }); | |
| 710 | + return; | |
| 711 | + } | |
| 712 | + | |
| 713 | + const body: ProductCreateInput = { | |
| 714 | + productCode: productCode.trim(), | |
| 715 | + productName: productName.trim(), | |
| 716 | + categoryName: categoryName.trim() || null, | |
| 717 | + productImageUrl: productImageUrl.trim() || null, | |
| 718 | + state, | |
| 719 | + }; | |
| 720 | + | |
| 721 | + setSubmitting(true); | |
| 722 | + try { | |
| 723 | + if (editing) { | |
| 724 | + await updateProduct(editing.id, body as ProductUpdateInput); | |
| 725 | + const prev = locationMap.get(editing.id) ?? []; | |
| 726 | + await syncProductStoreBinding(editing.id, locationId.trim(), prev); | |
| 727 | + } else { | |
| 728 | + const created = await createProduct(body); | |
| 729 | + await createProductLocation({ locationId: locationId.trim(), productIds: [created.id] }); | |
| 730 | + } | |
| 731 | + toast.success(editing ? "Product updated." : "Product created."); | |
| 732 | + onSaved(); | |
| 733 | + } catch (e: any) { | |
| 734 | + toast.error(editing ? "Update failed" : "Create failed", { | |
| 735 | + description: e?.message ? String(e.message) : "", | |
| 736 | + }); | |
| 737 | + } finally { | |
| 738 | + setSubmitting(false); | |
| 739 | + } | |
| 740 | + }; | |
| 326 | 741 | |
| 327 | -function CreateProductDialog({ open, onOpenChange, categories }: { open: boolean; onOpenChange: (open: boolean) => void; categories: any[] }) { | |
| 328 | - const [appearanceType, setAppearanceType] = useState('text'); | |
| 329 | - | |
| 330 | 742 | return ( |
| 331 | 743 | <Dialog open={open} onOpenChange={onOpenChange}> |
| 332 | - <DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto"> | |
| 744 | + <DialogContent className="w-[min(50%,calc(100vw-2rem))] max-w-none sm:max-w-none max-h-[90vh] overflow-y-auto"> | |
| 333 | 745 | <DialogHeader> |
| 334 | - <DialogTitle>Add New Product</DialogTitle> | |
| 746 | + <DialogTitle>{editing ? "Edit Product" : "Add New Product"}</DialogTitle> | |
| 335 | 747 | <DialogDescription> |
| 336 | - Create a new product. Fill in the details below. | |
| 748 | + {editing ? "Update product and store binding." : "Create a product and bind it to a store."} | |
| 337 | 749 | </DialogDescription> |
| 338 | 750 | </DialogHeader> |
| 339 | - | |
| 340 | - <div className="grid gap-6 py-4"> | |
| 341 | - | |
| 342 | - {/* Basic Info */} | |
| 343 | - <div className="grid grid-cols-2 gap-4"> | |
| 344 | - <div className="space-y-2"> | |
| 345 | - <Label>Product Category</Label> | |
| 346 | - <Select> | |
| 347 | - <SelectTrigger> | |
| 348 | - <SelectValue placeholder="Select Category" /> | |
| 349 | - </SelectTrigger> | |
| 350 | - <SelectContent> | |
| 351 | - {categories.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)} | |
| 352 | - </SelectContent> | |
| 353 | - </Select> | |
| 354 | - </div> | |
| 355 | - <div className="space-y-2"> | |
| 356 | - <Label>Product Name</Label> | |
| 357 | - <Input placeholder="e.g. Whole Milk" /> | |
| 358 | - </div> | |
| 359 | - </div> | |
| 360 | 751 | |
| 752 | + <div className="grid gap-4 py-4"> | |
| 361 | 753 | <div className="grid grid-cols-2 gap-4"> |
| 362 | 754 | <div className="space-y-2"> |
| 363 | - <Label>Product ID</Label> | |
| 364 | - <Input placeholder="Internal ID" /> | |
| 755 | + <Label>Product code *</Label> | |
| 756 | + <Input | |
| 757 | + className="h-10" | |
| 758 | + value={productCode} | |
| 759 | + onChange={(e) => setProductCode(e.target.value)} | |
| 760 | + placeholder="e.g. PRD_TEST_001" | |
| 761 | + /> | |
| 365 | 762 | </div> |
| 366 | 763 | <div className="space-y-2"> |
| 367 | - <Label>Status</Label> | |
| 368 | - <div className="flex items-center gap-2 mt-2"> | |
| 369 | - <Switch defaultChecked /> | |
| 370 | - <span className="text-sm text-gray-500">Active</span> | |
| 371 | - </div> | |
| 764 | + <Label>Product name *</Label> | |
| 765 | + <Input | |
| 766 | + className="h-10" | |
| 767 | + value={productName} | |
| 768 | + onChange={(e) => setProductName(e.target.value)} | |
| 769 | + placeholder="e.g. Chicken" | |
| 770 | + /> | |
| 372 | 771 | </div> |
| 373 | 772 | </div> |
| 374 | - | |
| 375 | - {/* Barcode Section */} | |
| 376 | - <div className="space-y-3 p-4 bg-gray-50 rounded-md border border-gray-100"> | |
| 377 | - <Label className="flex items-center gap-2"> | |
| 378 | - <Barcode className="w-4 h-4" /> Barcode Settings | |
| 379 | - </Label> | |
| 380 | - <div className="grid grid-cols-3 gap-3"> | |
| 381 | - <div className="col-span-1"> | |
| 382 | - <Select defaultValue="ean13"> | |
| 383 | - <SelectTrigger> | |
| 384 | - <SelectValue placeholder="Format" /> | |
| 385 | - </SelectTrigger> | |
| 386 | - <SelectContent> | |
| 387 | - <SelectItem value="ean13">EAN-13</SelectItem> | |
| 388 | - <SelectItem value="upc-a">UPC-A</SelectItem> | |
| 389 | - <SelectItem value="code128">Code 128</SelectItem> | |
| 390 | - <SelectItem value="qr">QR Code</SelectItem> | |
| 391 | - </SelectContent> | |
| 392 | - </Select> | |
| 393 | - </div> | |
| 394 | - <div className="col-span-2"> | |
| 395 | - <Input placeholder="Barcode Value" /> | |
| 396 | - </div> | |
| 397 | - </div> | |
| 398 | - <Button variant="link" className="px-0 text-xs h-auto">+ Add another barcode standard</Button> | |
| 773 | + <div className="space-y-2"> | |
| 774 | + <Label>Category name</Label> | |
| 775 | + <SearchableSelect | |
| 776 | + value={categoryName} | |
| 777 | + onValueChange={setCategoryName} | |
| 778 | + options={categoryOptions} | |
| 779 | + placeholder="Select category (optional)" | |
| 780 | + searchPlaceholder="Search category…" | |
| 781 | + emptyText="No categories." | |
| 782 | + /> | |
| 399 | 783 | </div> |
| 400 | - | |
| 401 | - {/* Appearance Section */} | |
| 402 | - <div className="space-y-3"> | |
| 403 | - <Label>App Appearance</Label> | |
| 404 | - <Tabs value={appearanceType} onValueChange={setAppearanceType} className="w-full"> | |
| 405 | - <TabsList className="grid w-full grid-cols-3"> | |
| 406 | - <TabsTrigger value="text" className="flex items-center gap-2"><Type className="w-3 h-3"/> Text</TabsTrigger> | |
| 407 | - <TabsTrigger value="color" className="flex items-center gap-2"><Palette className="w-3 h-3"/> Color</TabsTrigger> | |
| 408 | - <TabsTrigger value="image" className="flex items-center gap-2"><ImageIcon className="w-3 h-3"/> Image</TabsTrigger> | |
| 409 | - </TabsList> | |
| 410 | - | |
| 411 | - <TabsContent value="text" className="mt-4 space-y-2"> | |
| 412 | - <Label>Display Text</Label> | |
| 413 | - <Input placeholder="Text to display on button" /> | |
| 414 | - </TabsContent> | |
| 415 | - | |
| 416 | - <TabsContent value="color" className="mt-4 space-y-2"> | |
| 417 | - <Label>Select Color</Label> | |
| 418 | - <div className="flex gap-2 flex-wrap"> | |
| 419 | - {['#ef4444', '#f97316', '#f59e0b', '#84cc16', '#10b981', '#06b6d4', '#3b82f6', '#6366f1', '#a855f7', '#ec4899'].map(color => ( | |
| 420 | - <button key={color} className="w-8 h-8 rounded-full border border-gray-200 shadow-sm hover:scale-110 transition-transform" style={{ backgroundColor: color }} /> | |
| 421 | - ))} | |
| 422 | - <button className="w-8 h-8 rounded-full border border-dashed border-gray-400 flex items-center justify-center hover:bg-gray-50"> | |
| 423 | - <Plus className="w-4 h-4 text-gray-400" /> | |
| 424 | - </button> | |
| 425 | - </div> | |
| 426 | - </TabsContent> | |
| 427 | - | |
| 428 | - <TabsContent value="image" className="mt-4 space-y-2"> | |
| 429 | - <Label>Upload Image</Label> | |
| 430 | - <div className="border-2 border-dashed border-gray-300 rounded-md p-6 flex flex-col items-center justify-center text-gray-500 hover:bg-gray-50 cursor-pointer"> | |
| 431 | - <ImageIcon className="w-8 h-8 mb-2 opacity-50" /> | |
| 432 | - <span className="text-xs">Click to upload or drag and drop</span> | |
| 433 | - </div> | |
| 434 | - </TabsContent> | |
| 435 | - </Tabs> | |
| 784 | + <div className="space-y-2"> | |
| 785 | + <Label>Image URL</Label> | |
| 786 | + <Input | |
| 787 | + className="h-10" | |
| 788 | + value={productImageUrl} | |
| 789 | + onChange={(e) => setProductImageUrl(e.target.value)} | |
| 790 | + placeholder="https://..." | |
| 791 | + /> | |
| 436 | 792 | </div> |
| 437 | - | |
| 438 | - {/* Scope Assignment */} | |
| 439 | - <div className="space-y-3 pt-2 border-t border-gray-100"> | |
| 440 | - <Label>Store Availability</Label> | |
| 441 | - <div className="space-y-2"> | |
| 442 | - <div className="flex items-center space-x-2"> | |
| 443 | - <input type="radio" id="all-stores" name="scope" className="text-blue-600 focus:ring-blue-500" defaultChecked /> | |
| 444 | - <label htmlFor="all-stores" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> | |
| 445 | - All Stores in Group | |
| 446 | - </label> | |
| 447 | - </div> | |
| 448 | - <div className="flex items-center space-x-2"> | |
| 449 | - <input type="radio" id="specific-stores" name="scope" className="text-blue-600 focus:ring-blue-500" /> | |
| 450 | - <label htmlFor="specific-stores" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> | |
| 451 | - Specific Location(s) | |
| 452 | - </label> | |
| 453 | - </div> | |
| 454 | - </div> | |
| 455 | - {/* Mock Location Selector if specific is chosen */} | |
| 456 | - <div className="pl-6 pt-2"> | |
| 457 | - <Select disabled> | |
| 458 | - <SelectTrigger className="h-8 text-sm"> | |
| 459 | - <SelectValue placeholder="Select Locations..." /> | |
| 460 | - </SelectTrigger> | |
| 461 | - </Select> | |
| 462 | - </div> | |
| 793 | + <div className="space-y-2"> | |
| 794 | + <Label>Bind to store *</Label> | |
| 795 | + <SearchableSelect | |
| 796 | + value={locationId} | |
| 797 | + onValueChange={setLocationId} | |
| 798 | + options={locationOptions} | |
| 799 | + placeholder="Select location" | |
| 800 | + searchPlaceholder="Search location…" | |
| 801 | + emptyText="No locations." | |
| 802 | + /> | |
| 803 | + </div> | |
| 804 | + <div className="flex items-center justify-between border border-gray-200 rounded-md px-3 h-10 bg-white"> | |
| 805 | + <span className="text-sm font-medium">Enabled</span> | |
| 806 | + <Switch checked={state} onCheckedChange={setState} /> | |
| 463 | 807 | </div> |
| 464 | - | |
| 465 | 808 | </div> |
| 466 | 809 | |
| 467 | 810 | <DialogFooter> |
| 468 | - <Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button> | |
| 469 | - <Button onClick={() => onOpenChange(false)} className="bg-blue-600 hover:bg-blue-700 text-white">Create Product</Button> | |
| 811 | + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> | |
| 812 | + Cancel | |
| 813 | + </Button> | |
| 814 | + <Button type="button" disabled={submitting} onClick={submit} className="bg-blue-600 hover:bg-blue-700 text-white"> | |
| 815 | + {submitting ? "Saving…" : editing ? "Save" : "Create"} | |
| 816 | + </Button> | |
| 470 | 817 | </DialogFooter> |
| 471 | 818 | </DialogContent> |
| 472 | 819 | </Dialog> |
| 473 | 820 | ); |
| 474 | 821 | } |
| 475 | 822 | |
| 476 | -function CreateCategoryDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { | |
| 477 | - const [appearanceType, setAppearanceType] = useState('text'); | |
| 823 | +function DeleteProductDialog({ | |
| 824 | + open, | |
| 825 | + product, | |
| 826 | + onOpenChange, | |
| 827 | + onDeleted, | |
| 828 | +}: { | |
| 829 | + open: boolean; | |
| 830 | + product: ProductDto | null; | |
| 831 | + onOpenChange: (o: boolean) => void; | |
| 832 | + onDeleted: () => void; | |
| 833 | +}) { | |
| 834 | + const [submitting, setSubmitting] = useState(false); | |
| 835 | + | |
| 836 | + const submit = async () => { | |
| 837 | + if (!product?.id) return; | |
| 838 | + setSubmitting(true); | |
| 839 | + try { | |
| 840 | + await deleteProduct(product.id); | |
| 841 | + toast.success("Product deleted."); | |
| 842 | + onOpenChange(false); | |
| 843 | + onDeleted(); | |
| 844 | + } catch (e: any) { | |
| 845 | + toast.error("Delete failed", { description: e?.message ? String(e.message) : "" }); | |
| 846 | + } finally { | |
| 847 | + setSubmitting(false); | |
| 848 | + } | |
| 849 | + }; | |
| 850 | + | |
| 851 | + return ( | |
| 852 | + <Dialog open={open} onOpenChange={onOpenChange}> | |
| 853 | + <DialogContent className="sm:max-w-md"> | |
| 854 | + <DialogHeader> | |
| 855 | + <DialogTitle>Delete product</DialogTitle> | |
| 856 | + <DialogDescription>This cannot be undone.</DialogDescription> | |
| 857 | + </DialogHeader> | |
| 858 | + <p className="text-sm text-gray-700 py-2"> | |
| 859 | + Delete <span className="font-medium">{toDisplay(product?.productName)}</span> ({toDisplay(product?.productCode)})? | |
| 860 | + </p> | |
| 861 | + <DialogFooter> | |
| 862 | + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> | |
| 863 | + Cancel | |
| 864 | + </Button> | |
| 865 | + <Button type="button" variant="destructive" disabled={submitting} onClick={submit}> | |
| 866 | + {submitting ? "Deleting…" : "Delete"} | |
| 867 | + </Button> | |
| 868 | + </DialogFooter> | |
| 869 | + </DialogContent> | |
| 870 | + </Dialog> | |
| 871 | + ); | |
| 872 | +} | |
| 478 | 873 | |
| 874 | +function CreateCategoryPlaceholderDialog({ | |
| 875 | + open, | |
| 876 | + onOpenChange, | |
| 877 | +}: { | |
| 878 | + open: boolean; | |
| 879 | + onOpenChange: (o: boolean) => void; | |
| 880 | +}) { | |
| 479 | 881 | return ( |
| 480 | 882 | <Dialog open={open} onOpenChange={onOpenChange}> |
| 481 | - <DialogContent className="sm:max-w-[500px]"> | |
| 883 | + <DialogContent className="sm:max-w-md"> | |
| 482 | 884 | <DialogHeader> |
| 483 | - <DialogTitle>Add New Category</DialogTitle> | |
| 885 | + <DialogTitle>Categories</DialogTitle> | |
| 484 | 886 | <DialogDescription> |
| 485 | - Create a product category to organize your items. | |
| 887 | + Manage categories under <span className="font-medium">Labeling → Label Categories</span> (label module API). | |
| 486 | 888 | </DialogDescription> |
| 487 | 889 | </DialogHeader> |
| 488 | - | |
| 489 | - <div className="grid gap-6 py-4"> | |
| 490 | - <div className="space-y-2"> | |
| 491 | - <Label>Category Name</Label> | |
| 492 | - <Input placeholder="e.g. Dairy, Meat, Bakery" /> | |
| 493 | - </div> | |
| 494 | - | |
| 495 | - {/* Appearance Section */} | |
| 496 | - <div className="space-y-3"> | |
| 497 | - <Label>Button Appearance</Label> | |
| 498 | - <Tabs value={appearanceType} onValueChange={setAppearanceType} className="w-full"> | |
| 499 | - <TabsList className="grid w-full grid-cols-3"> | |
| 500 | - <TabsTrigger value="text" className="flex items-center gap-2"><Type className="w-3 h-3"/> Text</TabsTrigger> | |
| 501 | - <TabsTrigger value="color" className="flex items-center gap-2"><Palette className="w-3 h-3"/> Color</TabsTrigger> | |
| 502 | - <TabsTrigger value="image" className="flex items-center gap-2"><ImageIcon className="w-3 h-3"/> Image</TabsTrigger> | |
| 503 | - </TabsList> | |
| 504 | - | |
| 505 | - <TabsContent value="text" className="mt-4 space-y-2"> | |
| 506 | - <Label>Display Text</Label> | |
| 507 | - <Input placeholder="Category Name" /> | |
| 508 | - </TabsContent> | |
| 509 | - | |
| 510 | - <TabsContent value="color" className="mt-4 space-y-2"> | |
| 511 | - <Label>Select Color</Label> | |
| 512 | - <div className="flex gap-2 flex-wrap"> | |
| 513 | - {['#bfdbfe', '#bbf7d0', '#fecaca', '#ddd6fe', '#fde68a'].map(color => ( | |
| 514 | - <button key={color} className="w-8 h-8 rounded-full border border-gray-200 shadow-sm hover:scale-110 transition-transform" style={{ backgroundColor: color }} /> | |
| 515 | - ))} | |
| 516 | - <button className="w-8 h-8 rounded-full border border-dashed border-gray-400 flex items-center justify-center hover:bg-gray-50"> | |
| 517 | - <Plus className="w-4 h-4 text-gray-400" /> | |
| 518 | - </button> | |
| 519 | - </div> | |
| 520 | - </TabsContent> | |
| 521 | - | |
| 522 | - <TabsContent value="image" className="mt-4 space-y-2"> | |
| 523 | - <Label>Upload Icon/Image</Label> | |
| 524 | - <div className="border-2 border-dashed border-gray-300 rounded-md p-6 flex flex-col items-center justify-center text-gray-500 hover:bg-gray-50 cursor-pointer"> | |
| 525 | - <ImageIcon className="w-8 h-8 mb-2 opacity-50" /> | |
| 526 | - <span className="text-xs">Click to upload</span> | |
| 527 | - </div> | |
| 528 | - </TabsContent> | |
| 529 | - </Tabs> | |
| 530 | - </div> | |
| 531 | - | |
| 532 | - <div className="grid grid-cols-2 gap-4"> | |
| 533 | - <div className="space-y-2"> | |
| 534 | - <Label>Status</Label> | |
| 535 | - <div className="flex items-center gap-2 mt-2"> | |
| 536 | - <Switch defaultChecked /> | |
| 537 | - <span className="text-sm text-gray-500">Active</span> | |
| 538 | - </div> | |
| 539 | - </div> | |
| 540 | - </div> | |
| 541 | - | |
| 542 | - {/* Scope Assignment */} | |
| 543 | - <div className="space-y-3 pt-2 border-t border-gray-100"> | |
| 544 | - <Label>Store Availability</Label> | |
| 545 | - <div className="space-y-2"> | |
| 546 | - <div className="flex items-center space-x-2"> | |
| 547 | - <input type="radio" id="cat-all-stores" name="cat-scope" className="text-blue-600 focus:ring-blue-500" defaultChecked /> | |
| 548 | - <label htmlFor="cat-all-stores" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> | |
| 549 | - All Stores in Group | |
| 550 | - </label> | |
| 551 | - </div> | |
| 552 | - <div className="flex items-center space-x-2"> | |
| 553 | - <input type="radio" id="cat-specific-stores" name="cat-scope" className="text-blue-600 focus:ring-blue-500" /> | |
| 554 | - <label htmlFor="cat-specific-stores" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> | |
| 555 | - Specific Location(s) | |
| 556 | - </label> | |
| 557 | - </div> | |
| 558 | - </div> | |
| 559 | - </div> | |
| 560 | - | |
| 561 | - </div> | |
| 562 | - | |
| 563 | 890 | <DialogFooter> |
| 564 | - <Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button> | |
| 565 | - <Button onClick={() => onOpenChange(false)} className="bg-blue-600 hover:bg-blue-700 text-white">Create Category</Button> | |
| 891 | + <Button type="button" onClick={() => onOpenChange(false)}> | |
| 892 | + OK | |
| 893 | + </Button> | |
| 566 | 894 | </DialogFooter> |
| 567 | 895 | </DialogContent> |
| 568 | 896 | </Dialog> | ... | ... |
美国版/Food Labeling Management Platform/src/components/ui/searchable-select.tsx
0 → 100644
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import * as React from "react"; | |
| 4 | +import { useState } from "react"; | |
| 5 | +import { ChevronsUpDown } from "lucide-react"; | |
| 6 | +import { cn } from "./utils"; | |
| 7 | +import { Button } from "./button"; | |
| 8 | +import { | |
| 9 | + Command, | |
| 10 | + CommandEmpty, | |
| 11 | + CommandGroup, | |
| 12 | + CommandInput, | |
| 13 | + CommandItem, | |
| 14 | + CommandList, | |
| 15 | +} from "./command"; | |
| 16 | +import { Popover, PopoverContent, PopoverTrigger } from "./popover"; | |
| 17 | + | |
| 18 | +export type SearchableSelectOption = { | |
| 19 | + value: string; | |
| 20 | + label: string; | |
| 21 | +}; | |
| 22 | + | |
| 23 | +export function SearchableSelect({ | |
| 24 | + value, | |
| 25 | + onValueChange, | |
| 26 | + options, | |
| 27 | + placeholder = "Select…", | |
| 28 | + searchPlaceholder = "Search…", | |
| 29 | + emptyText = "No matching results.", | |
| 30 | + disabled, | |
| 31 | + className, | |
| 32 | +}: { | |
| 33 | + /** 空字符串表示未选择 */ | |
| 34 | + value: string; | |
| 35 | + onValueChange: (next: string) => void; | |
| 36 | + options: SearchableSelectOption[]; | |
| 37 | + placeholder?: string; | |
| 38 | + searchPlaceholder?: string; | |
| 39 | + emptyText?: string; | |
| 40 | + disabled?: boolean; | |
| 41 | + className?: string; | |
| 42 | +}) { | |
| 43 | + const [open, setOpen] = useState(false); | |
| 44 | + const hit = value ? options.find((o) => o.value === value) : undefined; | |
| 45 | + const selectedLabel = value ? hit?.label ?? value : null; | |
| 46 | + | |
| 47 | + return ( | |
| 48 | + <Popover open={open} onOpenChange={setOpen}> | |
| 49 | + <PopoverTrigger asChild> | |
| 50 | + <Button | |
| 51 | + type="button" | |
| 52 | + variant="outline" | |
| 53 | + role="combobox" | |
| 54 | + aria-expanded={open} | |
| 55 | + disabled={disabled} | |
| 56 | + className={cn( | |
| 57 | + "w-full justify-between h-10 px-3 font-normal border border-gray-300 bg-white", | |
| 58 | + className, | |
| 59 | + )} | |
| 60 | + > | |
| 61 | + <span | |
| 62 | + className={cn( | |
| 63 | + "truncate text-left text-sm", | |
| 64 | + !selectedLabel && "text-gray-500", | |
| 65 | + )} | |
| 66 | + > | |
| 67 | + {selectedLabel ?? placeholder} | |
| 68 | + </span> | |
| 69 | + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> | |
| 70 | + </Button> | |
| 71 | + </PopoverTrigger> | |
| 72 | + <PopoverContent | |
| 73 | + className="p-0 w-[var(--radix-popover-trigger-width)] max-w-[min(100vw-2rem,400px)]" | |
| 74 | + align="start" | |
| 75 | + > | |
| 76 | + <Command> | |
| 77 | + <CommandInput placeholder={searchPlaceholder} /> | |
| 78 | + <CommandList> | |
| 79 | + <CommandEmpty>{emptyText}</CommandEmpty> | |
| 80 | + <CommandGroup> | |
| 81 | + {options.map((opt) => ( | |
| 82 | + <CommandItem | |
| 83 | + key={opt.value} | |
| 84 | + value={`${opt.label} ${opt.value}`} | |
| 85 | + onSelect={() => { | |
| 86 | + onValueChange(opt.value); | |
| 87 | + setOpen(false); | |
| 88 | + }} | |
| 89 | + className={cn( | |
| 90 | + "cursor-pointer rounded-md px-2 py-2 transition-colors", | |
| 91 | + "hover:bg-gray-100 hover:text-gray-900", | |
| 92 | + "data-[selected=true]:bg-gray-100", | |
| 93 | + value === opt.value && | |
| 94 | + "bg-blue-50 text-gray-900 font-medium data-[selected=true]:bg-blue-100", | |
| 95 | + )} | |
| 96 | + > | |
| 97 | + <span className="truncate">{opt.label}</span> | |
| 98 | + </CommandItem> | |
| 99 | + ))} | |
| 100 | + </CommandGroup> | |
| 101 | + </CommandList> | |
| 102 | + </Command> | |
| 103 | + {value ? ( | |
| 104 | + <div className="border-t border-gray-100 px-2 py-1.5"> | |
| 105 | + <button | |
| 106 | + type="button" | |
| 107 | + className="text-xs text-gray-500 hover:text-gray-900 underline-offset-2 hover:underline" | |
| 108 | + onClick={() => { | |
| 109 | + onValueChange(""); | |
| 110 | + setOpen(false); | |
| 111 | + }} | |
| 112 | + > | |
| 113 | + Clear selection | |
| 114 | + </button> | |
| 115 | + </div> | |
| 116 | + ) : null} | |
| 117 | + </PopoverContent> | |
| 118 | + </Popover> | |
| 119 | + ); | |
| 120 | +} | ... | ... |
美国版/Food Labeling Management Platform/src/lib/apiClient.ts
| ... | ... | @@ -66,7 +66,7 @@ function getAbpErrorMessage(payload: unknown): string | null { |
| 66 | 66 | } |
| 67 | 67 | |
| 68 | 68 | function normalizePagedResultShape(x: unknown): unknown { |
| 69 | - // 部分接口直接把列表放在 data 里(无 items/totalCount) | |
| 69 | + // Some APIs return the list directly in `data` (no items/totalCount). | |
| 70 | 70 | if (Array.isArray(x)) { |
| 71 | 71 | return { items: x, totalCount: x.length }; |
| 72 | 72 | } |
| ... | ... | @@ -99,6 +99,14 @@ function normalizePagedResultShape(x: unknown): unknown { |
| 99 | 99 | items: (o as any).Data, |
| 100 | 100 | }; |
| 101 | 101 | } |
| 102 | + // 有 items 但 totalCount 缺失时,避免分页总数恒为 0 | |
| 103 | + if (Array.isArray(o.items) && typeof o.totalCount !== "number") { | |
| 104 | + const tc = o.TotalCount; | |
| 105 | + return { | |
| 106 | + ...o, | |
| 107 | + totalCount: typeof tc === "number" ? tc : o.items.length, | |
| 108 | + }; | |
| 109 | + } | |
| 102 | 110 | return x; |
| 103 | 111 | } |
| 104 | 112 | ... | ... |
美国版/Food Labeling Management Platform/src/services/labelCategoryService.ts
0 → 100644
| 1 | +import { createApiClient } from "../lib/apiClient"; | |
| 2 | +import type { | |
| 3 | + LabelCategoryCreateInput, | |
| 4 | + LabelCategoryDto, | |
| 5 | + LabelCategoryGetListInput, | |
| 6 | + LabelCategoryUpdateInput, | |
| 7 | + PagedResultDto, | |
| 8 | +} from "../types/labelCategory"; | |
| 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 | +const PATH = "/label-category"; | |
| 21 | + | |
| 22 | +export async function getLabelCategories(input: LabelCategoryGetListInput, signal?: AbortSignal): Promise<PagedResultDto<LabelCategoryDto>> { | |
| 23 | + return api.requestJson<PagedResultDto<LabelCategoryDto>>({ | |
| 24 | + path: PATH, | |
| 25 | + method: "GET", | |
| 26 | + query: { | |
| 27 | + SkipCount: input.skipCount, | |
| 28 | + MaxResultCount: input.maxResultCount, | |
| 29 | + Sorting: input.sorting, | |
| 30 | + Keyword: input.keyword, | |
| 31 | + State: input.state, | |
| 32 | + }, | |
| 33 | + signal, | |
| 34 | + }); | |
| 35 | +} | |
| 36 | + | |
| 37 | +export async function getLabelCategory(id: string, signal?: AbortSignal): Promise<LabelCategoryDto> { | |
| 38 | + return api.requestJson<LabelCategoryDto>({ | |
| 39 | + path: `${PATH}/${encodeURIComponent(id)}`, | |
| 40 | + method: "GET", | |
| 41 | + signal, | |
| 42 | + }); | |
| 43 | +} | |
| 44 | + | |
| 45 | +export async function createLabelCategory(input: LabelCategoryCreateInput): Promise<LabelCategoryDto> { | |
| 46 | + return api.requestJson<LabelCategoryDto>({ | |
| 47 | + path: PATH, | |
| 48 | + method: "POST", | |
| 49 | + body: { | |
| 50 | + categoryCode: input.categoryCode, | |
| 51 | + categoryName: input.categoryName, | |
| 52 | + categoryPhotoUrl: input.categoryPhotoUrl, | |
| 53 | + state: input.state ?? true, | |
| 54 | + orderNum: input.orderNum, | |
| 55 | + }, | |
| 56 | + }); | |
| 57 | +} | |
| 58 | + | |
| 59 | +export async function updateLabelCategory(id: string, input: LabelCategoryUpdateInput): Promise<LabelCategoryDto> { | |
| 60 | + return api.requestJson<LabelCategoryDto>({ | |
| 61 | + path: `${PATH}/${encodeURIComponent(id)}`, | |
| 62 | + method: "PUT", | |
| 63 | + body: { | |
| 64 | + categoryCode: input.categoryCode, | |
| 65 | + categoryName: input.categoryName, | |
| 66 | + categoryPhotoUrl: input.categoryPhotoUrl, | |
| 67 | + state: input.state ?? true, | |
| 68 | + orderNum: input.orderNum, | |
| 69 | + }, | |
| 70 | + }); | |
| 71 | +} | |
| 72 | + | |
| 73 | +export async function deleteLabelCategory(id: string): Promise<void> { | |
| 74 | + await api.requestJson<unknown>({ | |
| 75 | + path: `${PATH}/${encodeURIComponent(id)}`, | |
| 76 | + method: "DELETE", | |
| 77 | + }); | |
| 78 | +} | ... | ... |
美国版/Food Labeling Management Platform/src/services/labelMultipleOptionService.ts
0 → 100644
| 1 | +import { createApiClient } from "../lib/apiClient"; | |
| 2 | +import type { | |
| 3 | + LabelMultipleOptionCreateInput, | |
| 4 | + LabelMultipleOptionDto, | |
| 5 | + LabelMultipleOptionGetListInput, | |
| 6 | + LabelMultipleOptionUpdateInput, | |
| 7 | + PagedResultDto, | |
| 8 | +} from "../types/labelMultipleOption"; | |
| 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 | +const PATH = "/label-multiple-option"; | |
| 21 | + | |
| 22 | +/** 接口存的是 JSON 字符串;列表/详情可能返回 string 或已反序列化的 array */ | |
| 23 | +function parseOptionValuesJsonField(raw: unknown): string[] { | |
| 24 | + if (raw == null) return []; | |
| 25 | + if (Array.isArray(raw)) return raw.map((x) => String(x)); | |
| 26 | + if (typeof raw === "string") { | |
| 27 | + const t = raw.trim(); | |
| 28 | + if (!t) return []; | |
| 29 | + try { | |
| 30 | + const p = JSON.parse(t) as unknown; | |
| 31 | + if (Array.isArray(p)) return p.map((x) => String(x)); | |
| 32 | + return []; | |
| 33 | + } catch { | |
| 34 | + return []; | |
| 35 | + } | |
| 36 | + } | |
| 37 | + return []; | |
| 38 | +} | |
| 39 | + | |
| 40 | +function normalizeLabelMultipleOptionDto(item: LabelMultipleOptionDto): LabelMultipleOptionDto { | |
| 41 | + const raw = item as LabelMultipleOptionDto & { optionValuesJson?: unknown }; | |
| 42 | + return { | |
| 43 | + ...item, | |
| 44 | + optionValuesJson: parseOptionValuesJsonField(raw.optionValuesJson), | |
| 45 | + }; | |
| 46 | +} | |
| 47 | + | |
| 48 | +function normalizePaged(res: PagedResultDto<LabelMultipleOptionDto>): PagedResultDto<LabelMultipleOptionDto> { | |
| 49 | + return { | |
| 50 | + totalCount: res.totalCount ?? 0, | |
| 51 | + items: (res.items ?? []).map(normalizeLabelMultipleOptionDto), | |
| 52 | + }; | |
| 53 | +} | |
| 54 | + | |
| 55 | +/** 创建/更新 Body:`optionValuesJson` 须为 string[] 的 JSON 字符串 */ | |
| 56 | +function optionValuesJsonToApiBody(values: string[]): string { | |
| 57 | + return JSON.stringify(values); | |
| 58 | +} | |
| 59 | + | |
| 60 | +export async function getLabelMultipleOptions(input: LabelMultipleOptionGetListInput, signal?: AbortSignal): Promise<PagedResultDto<LabelMultipleOptionDto>> { | |
| 61 | + const res = await api.requestJson<PagedResultDto<LabelMultipleOptionDto>>({ | |
| 62 | + path: PATH, | |
| 63 | + method: "GET", | |
| 64 | + query: { | |
| 65 | + SkipCount: input.skipCount, | |
| 66 | + MaxResultCount: input.maxResultCount, | |
| 67 | + Sorting: input.sorting, | |
| 68 | + Keyword: input.keyword, | |
| 69 | + State: input.state, | |
| 70 | + }, | |
| 71 | + signal, | |
| 72 | + }); | |
| 73 | + return normalizePaged(res); | |
| 74 | +} | |
| 75 | + | |
| 76 | +export async function getLabelMultipleOption(id: string, signal?: AbortSignal): Promise<LabelMultipleOptionDto> { | |
| 77 | + const d = await api.requestJson<LabelMultipleOptionDto>({ | |
| 78 | + path: `${PATH}/${encodeURIComponent(id)}`, | |
| 79 | + method: "GET", | |
| 80 | + signal, | |
| 81 | + }); | |
| 82 | + return normalizeLabelMultipleOptionDto(d); | |
| 83 | +} | |
| 84 | + | |
| 85 | +export async function createLabelMultipleOption(input: LabelMultipleOptionCreateInput): Promise<LabelMultipleOptionDto> { | |
| 86 | + const d = await api.requestJson<LabelMultipleOptionDto>({ | |
| 87 | + path: PATH, | |
| 88 | + method: "POST", | |
| 89 | + body: { | |
| 90 | + optionCode: input.optionCode, | |
| 91 | + optionName: input.optionName, | |
| 92 | + optionValuesJson: optionValuesJsonToApiBody(input.optionValuesJson), | |
| 93 | + state: input.state ?? true, | |
| 94 | + orderNum: input.orderNum, | |
| 95 | + }, | |
| 96 | + }); | |
| 97 | + return normalizeLabelMultipleOptionDto(d); | |
| 98 | +} | |
| 99 | + | |
| 100 | +export async function updateLabelMultipleOption(id: string, input: LabelMultipleOptionUpdateInput): Promise<LabelMultipleOptionDto> { | |
| 101 | + const d = await api.requestJson<LabelMultipleOptionDto>({ | |
| 102 | + path: `${PATH}/${encodeURIComponent(id)}`, | |
| 103 | + method: "PUT", | |
| 104 | + body: { | |
| 105 | + optionCode: input.optionCode, | |
| 106 | + optionName: input.optionName, | |
| 107 | + optionValuesJson: optionValuesJsonToApiBody(input.optionValuesJson), | |
| 108 | + state: input.state ?? true, | |
| 109 | + orderNum: input.orderNum, | |
| 110 | + }, | |
| 111 | + }); | |
| 112 | + return normalizeLabelMultipleOptionDto(d); | |
| 113 | +} | |
| 114 | + | |
| 115 | +export async function deleteLabelMultipleOption(id: string): Promise<void> { | |
| 116 | + await api.requestJson<unknown>({ | |
| 117 | + path: `${PATH}/${encodeURIComponent(id)}`, | |
| 118 | + method: "DELETE", | |
| 119 | + }); | |
| 120 | +} | ... | ... |
美国版/Food Labeling Management Platform/src/services/labelService.ts
0 → 100644
| 1 | +import { createApiClient } from "../lib/apiClient"; | |
| 2 | +import type { | |
| 3 | + LabelCreateInput, | |
| 4 | + LabelDto, | |
| 5 | + LabelGetListInput, | |
| 6 | + LabelUpdateInput, | |
| 7 | + PagedResultDto, | |
| 8 | +} from "../types/label"; | |
| 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 | +const PATH = "/label"; | |
| 21 | + | |
| 22 | +export async function getLabels(input: LabelGetListInput, signal?: AbortSignal): Promise<PagedResultDto<LabelDto>> { | |
| 23 | + return api.requestJson<PagedResultDto<LabelDto>>({ | |
| 24 | + path: PATH, | |
| 25 | + method: "GET", | |
| 26 | + query: { | |
| 27 | + SkipCount: input.skipCount, | |
| 28 | + MaxResultCount: input.maxResultCount, | |
| 29 | + Sorting: input.sorting, | |
| 30 | + Keyword: input.keyword, | |
| 31 | + LocationId: input.locationId, | |
| 32 | + ProductId: input.productId, | |
| 33 | + LabelCategoryId: input.labelCategoryId, | |
| 34 | + LabelTypeId: input.labelTypeId, | |
| 35 | + TemplateCode: input.templateCode, | |
| 36 | + State: input.state, | |
| 37 | + }, | |
| 38 | + signal, | |
| 39 | + }); | |
| 40 | +} | |
| 41 | + | |
| 42 | +export async function getLabel(labelCode: string, signal?: AbortSignal): Promise<LabelDto> { | |
| 43 | + return api.requestJson<LabelDto>({ | |
| 44 | + path: `${PATH}/${encodeURIComponent(labelCode)}`, | |
| 45 | + method: "GET", | |
| 46 | + signal, | |
| 47 | + }); | |
| 48 | +} | |
| 49 | + | |
| 50 | +export async function createLabel(input: LabelCreateInput): Promise<LabelDto> { | |
| 51 | + return api.requestJson<LabelDto>({ | |
| 52 | + path: PATH, | |
| 53 | + method: "POST", | |
| 54 | + body: { | |
| 55 | + labelCode: input.labelCode, | |
| 56 | + labelName: input.labelName, | |
| 57 | + templateCode: input.templateCode, | |
| 58 | + locationId: input.locationId, | |
| 59 | + labelCategoryId: input.labelCategoryId, | |
| 60 | + labelTypeId: input.labelTypeId, | |
| 61 | + productIds: input.productIds, | |
| 62 | + labelInfoJson: input.labelInfoJson, | |
| 63 | + state: input.state ?? true, | |
| 64 | + }, | |
| 65 | + }); | |
| 66 | +} | |
| 67 | + | |
| 68 | +export async function updateLabel(labelCode: string, input: LabelUpdateInput): Promise<LabelDto> { | |
| 69 | + return api.requestJson<LabelDto>({ | |
| 70 | + path: `${PATH}/${encodeURIComponent(labelCode)}`, | |
| 71 | + method: "PUT", | |
| 72 | + body: { | |
| 73 | + labelName: input.labelName, | |
| 74 | + templateCode: input.templateCode, | |
| 75 | + locationId: input.locationId, | |
| 76 | + labelCategoryId: input.labelCategoryId, | |
| 77 | + labelTypeId: input.labelTypeId, | |
| 78 | + productIds: input.productIds, | |
| 79 | + labelInfoJson: input.labelInfoJson, | |
| 80 | + state: input.state ?? true, | |
| 81 | + }, | |
| 82 | + }); | |
| 83 | +} | |
| 84 | + | |
| 85 | +export async function deleteLabel(labelCode: string): Promise<void> { | |
| 86 | + await api.requestJson<unknown>({ | |
| 87 | + path: `${PATH}/${encodeURIComponent(labelCode)}`, | |
| 88 | + method: "DELETE", | |
| 89 | + }); | |
| 90 | +} | ... | ... |
美国版/Food Labeling Management Platform/src/services/labelTemplateService.ts
0 → 100644
| 1 | +import { createApiClient } from "../lib/apiClient"; | |
| 2 | +import type { | |
| 3 | + LabelTemplateCreateInput, | |
| 4 | + LabelTemplateDto, | |
| 5 | + LabelTemplateGetListInput, | |
| 6 | + LabelTemplateUpdateInput, | |
| 7 | + PagedResultDto, | |
| 8 | +} from "../types/labelTemplate"; | |
| 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 | +const PATH = "/label-template"; | |
| 21 | + | |
| 22 | +function normalizeTemplateCode(raw: unknown): string { | |
| 23 | + const r = raw as Record<string, unknown> | null | undefined; | |
| 24 | + if (!r || typeof r !== "object") return ""; | |
| 25 | + const id = r.id ?? r.templateCode ?? r.TemplateCode; | |
| 26 | + return typeof id === "string" ? id.trim() : String(id ?? "").trim(); | |
| 27 | +} | |
| 28 | + | |
| 29 | +function normalizeLabelTemplateDto(raw: unknown): LabelTemplateDto { | |
| 30 | + const r = raw as Record<string, unknown>; | |
| 31 | + const ids = | |
| 32 | + (Array.isArray(r.appliedLocationIds) ? r.appliedLocationIds : null) ?? | |
| 33 | + (Array.isArray(r.AppliedLocationIds) ? r.AppliedLocationIds : null) ?? | |
| 34 | + []; | |
| 35 | + const appliedLocationIds = ids.map((x) => String(x)); | |
| 36 | + const id = normalizeTemplateCode(raw); | |
| 37 | + | |
| 38 | + const templateNameVo = r.templateName ?? r.TemplateName; | |
| 39 | + const templateCodeVo = r.templateCode ?? r.TemplateCode; | |
| 40 | + const locationTextVo = r.locationText ?? r.LocationText; | |
| 41 | + const sizeTextVo = r.sizeText ?? r.SizeText; | |
| 42 | + const ccRaw = r.contentsCount ?? r.ContentsCount; | |
| 43 | + const contentsCountVo = typeof ccRaw === "number" ? ccRaw : undefined; | |
| 44 | + const lastEditedVo = r.lastEdited ?? r.LastEdited; | |
| 45 | + | |
| 46 | + const nameFromList = | |
| 47 | + (typeof r.name === "string" && r.name.trim() ? r.name : null) ?? | |
| 48 | + (typeof templateNameVo === "string" && String(templateNameVo).trim() ? String(templateNameVo) : null); | |
| 49 | + | |
| 50 | + return { | |
| 51 | + ...(r as object), | |
| 52 | + id, | |
| 53 | + name: nameFromList ?? (r.name as LabelTemplateDto["name"]), | |
| 54 | + templateName: (typeof templateNameVo === "string" ? templateNameVo : null) ?? (r.templateName as string | null), | |
| 55 | + templateCode: (typeof templateCodeVo === "string" ? templateCodeVo : null) ?? (r.templateCode as string | null), | |
| 56 | + locationText: (typeof locationTextVo === "string" ? locationTextVo : null) ?? (r.locationText as string | null), | |
| 57 | + sizeText: (typeof sizeTextVo === "string" ? sizeTextVo : null) ?? (r.sizeText as string | null), | |
| 58 | + contentsCount: contentsCountVo ?? (r.contentsCount as number | null), | |
| 59 | + lastEdited: (typeof lastEditedVo === "string" ? lastEditedVo : null) ?? (r.lastEdited as string | null), | |
| 60 | + appliedLocationIds, | |
| 61 | + elements: Array.isArray(r.elements) ? (r.elements as LabelTemplateDto["elements"]) : [], | |
| 62 | + } as LabelTemplateDto; | |
| 63 | +} | |
| 64 | + | |
| 65 | +export async function getLabelTemplates(input: LabelTemplateGetListInput, signal?: AbortSignal): Promise<PagedResultDto<LabelTemplateDto>> { | |
| 66 | + const res = await api.requestJson<PagedResultDto<LabelTemplateDto>>({ | |
| 67 | + path: PATH, | |
| 68 | + method: "GET", | |
| 69 | + query: { | |
| 70 | + SkipCount: input.skipCount, | |
| 71 | + MaxResultCount: input.maxResultCount, | |
| 72 | + Sorting: input.sorting, | |
| 73 | + Keyword: input.keyword, | |
| 74 | + LocationId: input.locationId, | |
| 75 | + LabelType: input.labelType, | |
| 76 | + State: input.state, | |
| 77 | + }, | |
| 78 | + signal, | |
| 79 | + }); | |
| 80 | + const items = (res.items ?? []).map((x) => normalizeLabelTemplateDto(x)); | |
| 81 | + return { ...res, items }; | |
| 82 | +} | |
| 83 | + | |
| 84 | +export async function getLabelTemplate(templateCode: string, signal?: AbortSignal): Promise<LabelTemplateDto> { | |
| 85 | + const raw = await api.requestJson<LabelTemplateDto>({ | |
| 86 | + path: `${PATH}/${encodeURIComponent(templateCode)}`, | |
| 87 | + method: "GET", | |
| 88 | + signal, | |
| 89 | + }); | |
| 90 | + return normalizeLabelTemplateDto(raw); | |
| 91 | +} | |
| 92 | + | |
| 93 | +export async function createLabelTemplate(input: LabelTemplateCreateInput): Promise<LabelTemplateDto> { | |
| 94 | + const created = await api.requestJson<LabelTemplateDto>({ | |
| 95 | + path: PATH, | |
| 96 | + method: "POST", | |
| 97 | + body: { | |
| 98 | + id: input.id, | |
| 99 | + name: input.name, | |
| 100 | + labelType: input.labelType, | |
| 101 | + unit: input.unit, | |
| 102 | + width: input.width, | |
| 103 | + height: input.height, | |
| 104 | + appliedLocation: input.appliedLocation, | |
| 105 | + showRuler: input.showRuler ?? true, | |
| 106 | + showGrid: input.showGrid ?? true, | |
| 107 | + state: input.state ?? true, | |
| 108 | + elements: input.elements, | |
| 109 | + appliedLocationIds: input.appliedLocationIds ?? [], | |
| 110 | + }, | |
| 111 | + }); | |
| 112 | + return normalizeLabelTemplateDto(created); | |
| 113 | +} | |
| 114 | + | |
| 115 | +export async function updateLabelTemplate(templateCode: string, input: LabelTemplateUpdateInput): Promise<LabelTemplateDto> { | |
| 116 | + const updated = await api.requestJson<LabelTemplateDto>({ | |
| 117 | + path: `${PATH}/${encodeURIComponent(templateCode)}`, | |
| 118 | + method: "PUT", | |
| 119 | + body: { | |
| 120 | + id: input.id, | |
| 121 | + name: input.name, | |
| 122 | + labelType: input.labelType, | |
| 123 | + unit: input.unit, | |
| 124 | + width: input.width, | |
| 125 | + height: input.height, | |
| 126 | + appliedLocation: input.appliedLocation, | |
| 127 | + showRuler: input.showRuler ?? true, | |
| 128 | + showGrid: input.showGrid ?? true, | |
| 129 | + state: input.state ?? true, | |
| 130 | + elements: input.elements, | |
| 131 | + appliedLocationIds: input.appliedLocationIds ?? [], | |
| 132 | + }, | |
| 133 | + }); | |
| 134 | + return normalizeLabelTemplateDto(updated); | |
| 135 | +} | |
| 136 | + | |
| 137 | +export async function deleteLabelTemplate(templateCode: string): Promise<void> { | |
| 138 | + await api.requestJson<unknown>({ | |
| 139 | + path: `${PATH}/${encodeURIComponent(templateCode)}`, | |
| 140 | + method: "DELETE", | |
| 141 | + }); | |
| 142 | +} | ... | ... |
美国版/Food Labeling Management Platform/src/services/labelTypeService.ts
0 → 100644
| 1 | +import { createApiClient } from "../lib/apiClient"; | |
| 2 | +import type { | |
| 3 | + LabelTypeCreateInput, | |
| 4 | + LabelTypeDto, | |
| 5 | + LabelTypeGetListInput, | |
| 6 | + LabelTypeUpdateInput, | |
| 7 | + PagedResultDto, | |
| 8 | +} from "../types/labelType"; | |
| 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 | +const PATH = "/label-type"; | |
| 21 | + | |
| 22 | +export async function getLabelTypes(input: LabelTypeGetListInput, signal?: AbortSignal): Promise<PagedResultDto<LabelTypeDto>> { | |
| 23 | + return api.requestJson<PagedResultDto<LabelTypeDto>>({ | |
| 24 | + path: PATH, | |
| 25 | + method: "GET", | |
| 26 | + query: { | |
| 27 | + SkipCount: input.skipCount, | |
| 28 | + MaxResultCount: input.maxResultCount, | |
| 29 | + Sorting: input.sorting, | |
| 30 | + Keyword: input.keyword, | |
| 31 | + State: input.state, | |
| 32 | + }, | |
| 33 | + signal, | |
| 34 | + }); | |
| 35 | +} | |
| 36 | + | |
| 37 | +export async function getLabelType(id: string, signal?: AbortSignal): Promise<LabelTypeDto> { | |
| 38 | + return api.requestJson<LabelTypeDto>({ | |
| 39 | + path: `${PATH}/${encodeURIComponent(id)}`, | |
| 40 | + method: "GET", | |
| 41 | + signal, | |
| 42 | + }); | |
| 43 | +} | |
| 44 | + | |
| 45 | +export async function createLabelType(input: LabelTypeCreateInput): Promise<LabelTypeDto> { | |
| 46 | + return api.requestJson<LabelTypeDto>({ | |
| 47 | + path: PATH, | |
| 48 | + method: "POST", | |
| 49 | + body: { | |
| 50 | + typeCode: input.typeCode, | |
| 51 | + typeName: input.typeName, | |
| 52 | + state: input.state ?? true, | |
| 53 | + orderNum: input.orderNum, | |
| 54 | + }, | |
| 55 | + }); | |
| 56 | +} | |
| 57 | + | |
| 58 | +export async function updateLabelType(id: string, input: LabelTypeUpdateInput): Promise<LabelTypeDto> { | |
| 59 | + return api.requestJson<LabelTypeDto>({ | |
| 60 | + path: `${PATH}/${encodeURIComponent(id)}`, | |
| 61 | + method: "PUT", | |
| 62 | + body: { | |
| 63 | + typeCode: input.typeCode, | |
| 64 | + typeName: input.typeName, | |
| 65 | + state: input.state ?? true, | |
| 66 | + orderNum: input.orderNum, | |
| 67 | + }, | |
| 68 | + }); | |
| 69 | +} | |
| 70 | + | |
| 71 | +export async function deleteLabelType(id: string): Promise<void> { | |
| 72 | + await api.requestJson<unknown>({ | |
| 73 | + path: `${PATH}/${encodeURIComponent(id)}`, | |
| 74 | + method: "DELETE", | |
| 75 | + }); | |
| 76 | +} | ... | ... |
美国版/Food Labeling Management Platform/src/services/productLocationService.ts
0 → 100644
| 1 | +import { createApiClient } from "../lib/apiClient"; | |
| 2 | +import type { | |
| 3 | + ProductLocationByStoreDto, | |
| 4 | + ProductLocationCreateInput, | |
| 5 | + ProductLocationGetListInput, | |
| 6 | + ProductLocationLinkDto, | |
| 7 | + ProductLocationUpdateInput, | |
| 8 | + PagedResultDto, | |
| 9 | +} from "../types/productLocation"; | |
| 10 | + | |
| 11 | +const api = createApiClient({ | |
| 12 | + getToken: () => { | |
| 13 | + try { | |
| 14 | + return localStorage.getItem("access_token") ?? localStorage.getItem("token") ?? null; | |
| 15 | + } catch { | |
| 16 | + return null; | |
| 17 | + } | |
| 18 | + }, | |
| 19 | +}); | |
| 20 | + | |
| 21 | +const PATH = "/product-location"; | |
| 22 | + | |
| 23 | +function normalizeLink(raw: unknown): ProductLocationLinkDto { | |
| 24 | + const r = raw as Record<string, unknown>; | |
| 25 | + return { | |
| 26 | + id: (r?.id ?? r?.Id) as string | undefined, | |
| 27 | + locationId: (r?.locationId ?? r?.LocationId) as string | null | undefined, | |
| 28 | + productId: (r?.productId ?? r?.ProductId) as string | null | undefined, | |
| 29 | + }; | |
| 30 | +} | |
| 31 | + | |
| 32 | +export async function getProductLocations( | |
| 33 | + input: ProductLocationGetListInput, | |
| 34 | + signal?: AbortSignal, | |
| 35 | +): Promise<PagedResultDto<ProductLocationLinkDto>> { | |
| 36 | + const res = await api.requestJson<PagedResultDto<ProductLocationLinkDto>>({ | |
| 37 | + path: PATH, | |
| 38 | + method: "GET", | |
| 39 | + query: { | |
| 40 | + SkipCount: input.skipCount, | |
| 41 | + MaxResultCount: input.maxResultCount, | |
| 42 | + Sorting: input.sorting, | |
| 43 | + LocationId: input.locationId, | |
| 44 | + ProductId: input.productId, | |
| 45 | + }, | |
| 46 | + signal, | |
| 47 | + }); | |
| 48 | + return { | |
| 49 | + ...res, | |
| 50 | + items: (res.items ?? []).map((x) => normalizeLink(x)), | |
| 51 | + }; | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** 门店下已关联的产品(文档 7.2) */ | |
| 55 | +export async function getProductIdsByLocation(locationId: string, signal?: AbortSignal): Promise<string[]> { | |
| 56 | + const raw = await api.requestJson<ProductLocationByStoreDto>({ | |
| 57 | + path: `${PATH}/${encodeURIComponent(locationId)}`, | |
| 58 | + method: "GET", | |
| 59 | + signal, | |
| 60 | + }); | |
| 61 | + if (Array.isArray(raw?.productIds)) return raw.productIds.map(String); | |
| 62 | + if (Array.isArray(raw?.items)) { | |
| 63 | + return (raw.items ?? []) | |
| 64 | + .map((x) => x?.productId) | |
| 65 | + .filter((x): x is string => typeof x === "string" && x.length > 0); | |
| 66 | + } | |
| 67 | + return []; | |
| 68 | +} | |
| 69 | + | |
| 70 | +export async function createProductLocation(input: ProductLocationCreateInput): Promise<unknown> { | |
| 71 | + return api.requestJson<unknown>({ | |
| 72 | + path: PATH, | |
| 73 | + method: "POST", | |
| 74 | + body: { | |
| 75 | + locationId: input.locationId, | |
| 76 | + productIds: input.productIds, | |
| 77 | + }, | |
| 78 | + }); | |
| 79 | +} | |
| 80 | + | |
| 81 | +export async function updateProductLocation(locationId: string, input: ProductLocationUpdateInput): Promise<unknown> { | |
| 82 | + return api.requestJson<unknown>({ | |
| 83 | + path: `${PATH}/${encodeURIComponent(locationId)}`, | |
| 84 | + method: "PUT", | |
| 85 | + body: { | |
| 86 | + productIds: input.productIds, | |
| 87 | + }, | |
| 88 | + }); | |
| 89 | +} | |
| 90 | + | |
| 91 | +export async function deleteProductLocation(locationId: string): Promise<void> { | |
| 92 | + await api.requestJson<unknown>({ | |
| 93 | + path: `${PATH}/${encodeURIComponent(locationId)}`, | |
| 94 | + method: "DELETE", | |
| 95 | + }); | |
| 96 | +} | ... | ... |
美国版/Food Labeling Management Platform/src/services/productService.ts
0 → 100644
| 1 | +import { createApiClient } from "../lib/apiClient"; | |
| 2 | +import type { | |
| 3 | + ProductCreateInput, | |
| 4 | + ProductDto, | |
| 5 | + ProductGetListInput, | |
| 6 | + ProductUpdateInput, | |
| 7 | + PagedResultDto, | |
| 8 | +} from "../types/product"; | |
| 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 | +const PATH = "/product"; | |
| 21 | + | |
| 22 | +function normalizeProductDto(raw: unknown): ProductDto { | |
| 23 | + const r = raw as Record<string, unknown>; | |
| 24 | + const id = String(r?.id ?? r?.Id ?? "").trim(); | |
| 25 | + return { | |
| 26 | + ...(r as object), | |
| 27 | + id, | |
| 28 | + productCode: (r?.productCode ?? r?.ProductCode) as string | null | undefined, | |
| 29 | + productName: (r?.productName ?? r?.ProductName) as string | null | undefined, | |
| 30 | + categoryName: (r?.categoryName ?? r?.CategoryName) as string | null | undefined, | |
| 31 | + productImageUrl: (r?.productImageUrl ?? r?.ProductImageUrl) as string | null | undefined, | |
| 32 | + state: | |
| 33 | + typeof r?.state === "boolean" | |
| 34 | + ? r.state | |
| 35 | + : typeof r?.State === "boolean" | |
| 36 | + ? (r.State as boolean) | |
| 37 | + : null, | |
| 38 | + } as ProductDto; | |
| 39 | +} | |
| 40 | + | |
| 41 | +export async function getProducts(input: ProductGetListInput, signal?: AbortSignal): Promise<PagedResultDto<ProductDto>> { | |
| 42 | + const res = await api.requestJson<PagedResultDto<ProductDto>>({ | |
| 43 | + path: PATH, | |
| 44 | + method: "GET", | |
| 45 | + query: { | |
| 46 | + SkipCount: input.skipCount, | |
| 47 | + MaxResultCount: input.maxResultCount, | |
| 48 | + Sorting: input.sorting, | |
| 49 | + Keyword: input.keyword, | |
| 50 | + State: input.state, | |
| 51 | + }, | |
| 52 | + signal, | |
| 53 | + }); | |
| 54 | + return { | |
| 55 | + ...res, | |
| 56 | + items: (res.items ?? []).map((x) => normalizeProductDto(x)), | |
| 57 | + }; | |
| 58 | +} | |
| 59 | + | |
| 60 | +export async function getProduct(id: string, signal?: AbortSignal): Promise<ProductDto> { | |
| 61 | + const raw = await api.requestJson<ProductDto>({ | |
| 62 | + path: `${PATH}/${encodeURIComponent(id)}`, | |
| 63 | + method: "GET", | |
| 64 | + signal, | |
| 65 | + }); | |
| 66 | + return normalizeProductDto(raw); | |
| 67 | +} | |
| 68 | + | |
| 69 | +export async function createProduct(input: ProductCreateInput): Promise<ProductDto> { | |
| 70 | + const raw = await api.requestJson<ProductDto>({ | |
| 71 | + path: PATH, | |
| 72 | + method: "POST", | |
| 73 | + body: { | |
| 74 | + productCode: input.productCode, | |
| 75 | + productName: input.productName, | |
| 76 | + categoryName: input.categoryName ?? null, | |
| 77 | + productImageUrl: input.productImageUrl ?? null, | |
| 78 | + state: input.state ?? true, | |
| 79 | + }, | |
| 80 | + }); | |
| 81 | + return normalizeProductDto(raw); | |
| 82 | +} | |
| 83 | + | |
| 84 | +export async function updateProduct(id: string, input: ProductUpdateInput): Promise<ProductDto> { | |
| 85 | + const raw = await api.requestJson<ProductDto>({ | |
| 86 | + path: `${PATH}/${encodeURIComponent(id)}`, | |
| 87 | + method: "PUT", | |
| 88 | + body: { | |
| 89 | + productCode: input.productCode, | |
| 90 | + productName: input.productName, | |
| 91 | + categoryName: input.categoryName ?? null, | |
| 92 | + productImageUrl: input.productImageUrl ?? null, | |
| 93 | + state: input.state ?? true, | |
| 94 | + }, | |
| 95 | + }); | |
| 96 | + return normalizeProductDto(raw); | |
| 97 | +} | |
| 98 | + | |
| 99 | +export async function deleteProduct(id: string): Promise<void> { | |
| 100 | + await api.requestJson<unknown>({ | |
| 101 | + path: `${PATH}/${encodeURIComponent(id)}`, | |
| 102 | + method: "DELETE", | |
| 103 | + }); | |
| 104 | +} | ... | ... |
美国版/Food Labeling Management Platform/src/types/label.ts
0 → 100644
| 1 | +export type LabelDto = { | |
| 2 | + id: string; // LabelCode | |
| 3 | + labelCode?: string | null; | |
| 4 | + labelName?: string | null; | |
| 5 | + templateCode?: string | null; | |
| 6 | + locationId?: string | null; | |
| 7 | + labelCategoryId?: string | null; | |
| 8 | + labelTypeId?: string | null; | |
| 9 | + productIds?: string[] | null; | |
| 10 | + labelInfoJson?: Record<string, unknown> | null; | |
| 11 | + state?: boolean | null; | |
| 12 | + creationTime?: string | null; | |
| 13 | + /** 列表接口:门店展示名 */ | |
| 14 | + locationName?: string | null; | |
| 15 | + /** 列表接口:标签类别展示名 */ | |
| 16 | + labelCategoryName?: string | null; | |
| 17 | + /** 列表接口:标签类型展示名 */ | |
| 18 | + labelTypeName?: string | null; | |
| 19 | + /** 列表接口:模板展示名 */ | |
| 20 | + templateName?: string | null; | |
| 21 | + /** 列表接口:关联产品展示名(按产品展开时可能为单条) */ | |
| 22 | + productName?: string | null; | |
| 23 | + /** 列表接口:产品类别展示名 */ | |
| 24 | + productCategoryName?: string | null; | |
| 25 | + /** 列表接口:最后编辑时间(后端已格式化字符串) */ | |
| 26 | + lastEdited?: string | null; | |
| 27 | +}; | |
| 28 | + | |
| 29 | +export type PagedResultDto<T> = { | |
| 30 | + totalCount: number; | |
| 31 | + items: T[]; | |
| 32 | +}; | |
| 33 | + | |
| 34 | +export type LabelGetListInput = { | |
| 35 | + skipCount: number; | |
| 36 | + maxResultCount: number; | |
| 37 | + sorting?: string; | |
| 38 | + keyword?: string; | |
| 39 | + locationId?: string; | |
| 40 | + productId?: string; | |
| 41 | + labelCategoryId?: string; | |
| 42 | + labelTypeId?: string; | |
| 43 | + templateCode?: string; | |
| 44 | + state?: boolean; | |
| 45 | +}; | |
| 46 | + | |
| 47 | +export type LabelCreateInput = { | |
| 48 | + labelCode: string; | |
| 49 | + labelName: string; | |
| 50 | + templateCode: string; | |
| 51 | + locationId: string; | |
| 52 | + labelCategoryId: string; | |
| 53 | + labelTypeId: string; | |
| 54 | + productIds: string[]; // 至少 1 个 | |
| 55 | + labelInfoJson?: Record<string, unknown> | null; | |
| 56 | + state?: boolean; | |
| 57 | +}; | |
| 58 | + | |
| 59 | +export type LabelUpdateInput = { | |
| 60 | + labelName: string; | |
| 61 | + templateCode: string; | |
| 62 | + locationId: string; | |
| 63 | + labelCategoryId: string; | |
| 64 | + labelTypeId: string; | |
| 65 | + productIds: string[]; // 至少 1 个 | |
| 66 | + labelInfoJson?: Record<string, unknown> | null; | |
| 67 | + state?: boolean; | |
| 68 | +}; | ... | ... |
美国版/Food Labeling Management Platform/src/types/labelCategory.ts
0 → 100644
| 1 | +export type LabelCategoryDto = { | |
| 2 | + id: string; | |
| 3 | + categoryCode?: string | null; | |
| 4 | + categoryName?: string | null; | |
| 5 | + categoryPhotoUrl?: 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 LabelCategoryGetListInput = { | |
| 17 | + skipCount: number; | |
| 18 | + maxResultCount: number; | |
| 19 | + sorting?: string; | |
| 20 | + keyword?: string; | |
| 21 | + state?: boolean; | |
| 22 | +}; | |
| 23 | + | |
| 24 | +export type LabelCategoryCreateInput = { | |
| 25 | + categoryCode: string; | |
| 26 | + categoryName: string; | |
| 27 | + categoryPhotoUrl?: string | null; | |
| 28 | + state?: boolean; | |
| 29 | + orderNum?: number | null; | |
| 30 | +}; | |
| 31 | + | |
| 32 | +export type LabelCategoryUpdateInput = LabelCategoryCreateInput; | ... | ... |
美国版/Food Labeling Management Platform/src/types/labelMultipleOption.ts
0 → 100644
| 1 | +export type LabelMultipleOptionDto = { | |
| 2 | + id: string; | |
| 3 | + optionCode?: string | null; | |
| 4 | + optionName?: string | null; | |
| 5 | + /** 业务层统一为 string[];接口传输为 JSON 字符串,由 service 解析/序列化 */ | |
| 6 | + optionValuesJson?: string[] | null; | |
| 7 | + state?: boolean | null; | |
| 8 | + orderNum?: number | null; | |
| 9 | + creationTime?: string | null; | |
| 10 | +}; | |
| 11 | + | |
| 12 | +export type PagedResultDto<T> = { | |
| 13 | + totalCount: number; | |
| 14 | + items: T[]; | |
| 15 | +}; | |
| 16 | + | |
| 17 | +export type LabelMultipleOptionGetListInput = { | |
| 18 | + skipCount: number; | |
| 19 | + maxResultCount: number; | |
| 20 | + sorting?: string; | |
| 21 | + keyword?: string; | |
| 22 | + state?: boolean; | |
| 23 | +}; | |
| 24 | + | |
| 25 | +export type LabelMultipleOptionCreateInput = { | |
| 26 | + optionCode: string; | |
| 27 | + optionName: string; | |
| 28 | + /** 表单用 string[];POST/PUT 时序列化为 JSON 字符串再提交 */ | |
| 29 | + optionValuesJson: string[]; | |
| 30 | + state?: boolean; | |
| 31 | + orderNum?: number | null; | |
| 32 | +}; | |
| 33 | + | |
| 34 | +export type LabelMultipleOptionUpdateInput = LabelMultipleOptionCreateInput; | ... | ... |
美国版/Food Labeling Management Platform/src/types/labelTemplate.ts
| ... | ... | @@ -4,7 +4,8 @@ |
| 4 | 4 | |
| 5 | 5 | export type LabelType = 'PRICE' | 'NUTRITION' | 'SHIPPING'; |
| 6 | 6 | export type Unit = 'cm' | 'inch'; |
| 7 | -export type AppliedLocation = 'ALL' | string; | |
| 7 | +/** 与接口文档一致:ALL=全部门店,SPECIFIED=指定门店(需 appliedLocationIds) */ | |
| 8 | +export type AppliedLocation = 'ALL' | 'SPECIFIED'; | |
| 8 | 9 | export type Rotation = 'horizontal' | 'vertical'; |
| 9 | 10 | export type Border = 'none' | 'line' | 'dotted'; |
| 10 | 11 | |
| ... | ... | @@ -31,6 +32,8 @@ export interface LabelTemplate { |
| 31 | 32 | width: number; |
| 32 | 33 | height: number; |
| 33 | 34 | appliedLocation: AppliedLocation; |
| 35 | + /** 当 appliedLocation=SPECIFIED 时必填,至少一个门店 Id */ | |
| 36 | + appliedLocationIds?: string[]; | |
| 34 | 37 | showRuler: boolean; |
| 35 | 38 | /** 是否显示画布网格,默认 true */ |
| 36 | 39 | showGrid?: boolean; |
| ... | ... | @@ -46,6 +49,11 @@ export interface LabelElement { |
| 46 | 49 | height: number; |
| 47 | 50 | rotation: Rotation; |
| 48 | 51 | border: Border; |
| 52 | + /** 与后端 editor JSON 对齐的可选字段 */ | |
| 53 | + orderNum?: number; | |
| 54 | + zIndex?: number; | |
| 55 | + valueSourceType?: string; | |
| 56 | + isRequiredInput?: boolean; | |
| 49 | 57 | config: Record<string, unknown>; |
| 50 | 58 | } |
| 51 | 59 | |
| ... | ... | @@ -97,6 +105,7 @@ export function createDefaultTemplate(id?: string): LabelTemplate { |
| 97 | 105 | width: 6, |
| 98 | 106 | height: 4, |
| 99 | 107 | appliedLocation: 'ALL', |
| 108 | + appliedLocationIds: [], | |
| 100 | 109 | showRuler: true, |
| 101 | 110 | showGrid: true, |
| 102 | 111 | elements: [], |
| ... | ... | @@ -148,3 +157,90 @@ export function createDefaultElement(type: ElementType, x = 20, y = 20): LabelEl |
| 148 | 157 | config: { ...d.config }, |
| 149 | 158 | }; |
| 150 | 159 | } |
| 160 | + | |
| 161 | +// ========== API 相关类型定义 ========== | |
| 162 | + | |
| 163 | +export type LabelTemplateDto = { | |
| 164 | + id: string; // TemplateCode(兼容后端 templateCode) | |
| 165 | + name?: string | null; | |
| 166 | + /** 列表接口常见展示名(与 name 二选一或并存) */ | |
| 167 | + templateName?: string | null; | |
| 168 | + /** 列表接口模板编码(与 id 一致时可省略) */ | |
| 169 | + templateCode?: string | null; | |
| 170 | + /** 列表接口:适用门店展示文案 */ | |
| 171 | + locationText?: string | null; | |
| 172 | + /** 列表接口:尺寸展示文案,如 6.00x4.00cm */ | |
| 173 | + sizeText?: string | null; | |
| 174 | + /** List API: element count (when no `elements` array). */ | |
| 175 | + contentsCount?: number | null; | |
| 176 | + /** 列表接口:最近编辑时间 */ | |
| 177 | + lastEdited?: string | null; | |
| 178 | + labelType?: LabelType | null; | |
| 179 | + unit?: Unit | null; | |
| 180 | + width?: number | null; | |
| 181 | + height?: number | null; | |
| 182 | + appliedLocation?: AppliedLocation | string | null; | |
| 183 | + showRuler?: boolean | null; | |
| 184 | + showGrid?: boolean | null; | |
| 185 | + state?: boolean | null; | |
| 186 | + versionNo?: number | null; | |
| 187 | + elements?: LabelElement[] | null; | |
| 188 | + appliedLocationIds?: string[] | null; | |
| 189 | + creationTime?: string | null; | |
| 190 | +}; | |
| 191 | + | |
| 192 | +export type PagedResultDto<T> = { | |
| 193 | + totalCount: number; | |
| 194 | + items: T[]; | |
| 195 | +}; | |
| 196 | + | |
| 197 | +export type LabelTemplateGetListInput = { | |
| 198 | + skipCount: number; | |
| 199 | + maxResultCount: number; | |
| 200 | + sorting?: string; | |
| 201 | + keyword?: string; | |
| 202 | + locationId?: string; | |
| 203 | + labelType?: LabelType; | |
| 204 | + state?: boolean; | |
| 205 | +}; | |
| 206 | + | |
| 207 | +export type LabelTemplateCreateInput = { | |
| 208 | + id: string; // TemplateCode | |
| 209 | + name: string; | |
| 210 | + labelType: LabelType; | |
| 211 | + unit: Unit; | |
| 212 | + width: number; | |
| 213 | + height: number; | |
| 214 | + appliedLocation: AppliedLocation; | |
| 215 | + showRuler?: boolean; | |
| 216 | + showGrid?: boolean; | |
| 217 | + state?: boolean; | |
| 218 | + elements: LabelElement[]; | |
| 219 | + appliedLocationIds?: string[]; // 当 appliedLocation=SPECIFIED 时必填 | |
| 220 | +}; | |
| 221 | + | |
| 222 | +export type LabelTemplateUpdateInput = LabelTemplateCreateInput; | |
| 223 | + | |
| 224 | +/** 将列表/详情 DTO 的 appliedLocation 规范为编辑器使用的 ALL | SPECIFIED */ | |
| 225 | +export function appliedLocationToEditor(dto: { | |
| 226 | + appliedLocation?: string | null; | |
| 227 | + appliedLocationIds?: string[] | null; | |
| 228 | +}): AppliedLocation { | |
| 229 | + const raw = String(dto.appliedLocation ?? "").trim().toUpperCase(); | |
| 230 | + if (raw === "ALL") return "ALL"; | |
| 231 | + if (raw === "SPECIFIED") return "SPECIFIED"; | |
| 232 | + if ((dto.appliedLocationIds?.length ?? 0) > 0) return "SPECIFIED"; | |
| 233 | + return "ALL"; | |
| 234 | +} | |
| 235 | + | |
| 236 | +/** 文档示例中的 valueSourceType,保存时缺省则按元素类型推断 */ | |
| 237 | +export function defaultValueSourceTypeForElement(type: ElementType): string { | |
| 238 | + switch (type) { | |
| 239 | + case "TEXT_PRODUCT": | |
| 240 | + return "PRODUCT"; | |
| 241 | + case "TEXT_PRICE": | |
| 242 | + return "PRICE"; | |
| 243 | + default: | |
| 244 | + return "FIXED"; | |
| 245 | + } | |
| 246 | +} | ... | ... |
美国版/Food Labeling Management Platform/src/types/labelType.ts
0 → 100644
| 1 | +export type LabelTypeDto = { | |
| 2 | + id: string; | |
| 3 | + typeCode?: string | null; | |
| 4 | + typeName?: string | null; | |
| 5 | + state?: boolean | null; | |
| 6 | + orderNum?: number | null; | |
| 7 | + creationTime?: string | null; | |
| 8 | +}; | |
| 9 | + | |
| 10 | +export type PagedResultDto<T> = { | |
| 11 | + totalCount: number; | |
| 12 | + items: T[]; | |
| 13 | +}; | |
| 14 | + | |
| 15 | +export type LabelTypeGetListInput = { | |
| 16 | + skipCount: number; | |
| 17 | + maxResultCount: number; | |
| 18 | + sorting?: string; | |
| 19 | + keyword?: string; | |
| 20 | + state?: boolean; | |
| 21 | +}; | |
| 22 | + | |
| 23 | +export type LabelTypeCreateInput = { | |
| 24 | + typeCode: string; | |
| 25 | + typeName: string; | |
| 26 | + state?: boolean; | |
| 27 | + orderNum?: number | null; | |
| 28 | +}; | |
| 29 | + | |
| 30 | +export type LabelTypeUpdateInput = LabelTypeCreateInput; | ... | ... |
美国版/Food Labeling Management Platform/src/types/product.ts
0 → 100644
| 1 | +export type ProductDto = { | |
| 2 | + id: string; | |
| 3 | + productCode?: string | null; | |
| 4 | + productName?: string | null; | |
| 5 | + categoryName?: string | null; | |
| 6 | + productImageUrl?: string | null; | |
| 7 | + state?: boolean | null; | |
| 8 | + creationTime?: string | null; | |
| 9 | +}; | |
| 10 | + | |
| 11 | +export type PagedResultDto<T> = { | |
| 12 | + totalCount: number; | |
| 13 | + items: T[]; | |
| 14 | +}; | |
| 15 | + | |
| 16 | +export type ProductGetListInput = { | |
| 17 | + skipCount: number; | |
| 18 | + maxResultCount: number; | |
| 19 | + sorting?: string; | |
| 20 | + keyword?: string; | |
| 21 | + state?: boolean; | |
| 22 | +}; | |
| 23 | + | |
| 24 | +export type ProductCreateInput = { | |
| 25 | + productCode: string; | |
| 26 | + productName: string; | |
| 27 | + categoryName?: string | null; | |
| 28 | + productImageUrl?: string | null; | |
| 29 | + state?: boolean; | |
| 30 | +}; | |
| 31 | + | |
| 32 | +export type ProductUpdateInput = ProductCreateInput; | ... | ... |
美国版/Food Labeling Management Platform/src/types/productLocation.ts
0 → 100644
| 1 | +export type ProductLocationLinkDto = { | |
| 2 | + id?: string | null; | |
| 3 | + locationId?: string | null; | |
| 4 | + productId?: string | null; | |
| 5 | +}; | |
| 6 | + | |
| 7 | +export type PagedResultDto<T> = { | |
| 8 | + totalCount: number; | |
| 9 | + items: T[]; | |
| 10 | +}; | |
| 11 | + | |
| 12 | +export type ProductLocationGetListInput = { | |
| 13 | + skipCount: number; | |
| 14 | + maxResultCount: number; | |
| 15 | + sorting?: string; | |
| 16 | + locationId?: string; | |
| 17 | + productId?: string; | |
| 18 | +}; | |
| 19 | + | |
| 20 | +export type ProductLocationCreateInput = { | |
| 21 | + locationId: string; | |
| 22 | + productIds: string[]; | |
| 23 | +}; | |
| 24 | + | |
| 25 | +export type ProductLocationUpdateInput = { | |
| 26 | + productIds: string[]; | |
| 27 | +}; | |
| 28 | + | |
| 29 | +/** GET /product-location/{locationId} — 以后端实际字段为准 */ | |
| 30 | +export type ProductLocationByStoreDto = { | |
| 31 | + locationId?: string | null; | |
| 32 | + productIds?: string[] | null; | |
| 33 | + items?: { productId?: string | null }[] | null; | |
| 34 | +}; | ... | ... |