Commit 0e27ddc820fd26dd05165eb2a4241b0df5cb2ccd

Authored by 杨鑫
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
1   -VITE_API_BASE_URL=http://192.168.31.87:19001
2   -
  1 +# VITE_API_BASE_URL=http://192.168.31.87:19001
  2 +VITE_API_BASE_URL=http://flus-test.3ffoodsafety.com
... ...
美国版/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
... ... @@ -933,7 +933,7 @@ export function LabelCanvas({
933 933 );
934 934 }
935 935  
936   -/** 仅用于预览:无网格、无标尺、无拖拽,按比例缩放 */
  936 +/** Preview only: no grid, no rulers, no drag; scale to fit. */
937 937 export function LabelPreviewOnly({
938 938 template,
939 939 maxWidth = 480,
... ...
美国版/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 &quot;New Label Template&quot; 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 &quot;New Label Template&quot; 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 +};
... ...