Commit abb6bea57ac70e50351cdd779d17df4e5dbcf209
1 parent
1bdbd1fc
用户管理
Showing
13 changed files
with
1870 additions
and
587 deletions
美国版/Food Labeling Management Platform/.env.local
美国版/Food Labeling Management Platform/build/assets/index-2Xatwc8-.js deleted
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/index-TU5tblcP.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-2Xatwc8-.js"></script> | |
| 8 | + <script type="module" crossorigin src="/assets/index-TU5tblcP.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/LabelTemplateEditor/LabelCanvas.tsx
| ... | ... | @@ -708,7 +708,6 @@ export function LabelCanvas({ |
| 708 | 708 | "flex-1 overflow-auto bg-gray-100 relative", |
| 709 | 709 | isSpacePressed ? "cursor-grab active:cursor-grabbing" : "" |
| 710 | 710 | )} |
| 711 | - onClick={canvasClick} | |
| 712 | 711 | onPointerDown={handleContainerPointerDown} |
| 713 | 712 | onPointerMove={handleContainerPointerMove} |
| 714 | 713 | onPointerUp={handleContainerPointerUp} |
| ... | ... | @@ -745,6 +744,15 @@ export function LabelCanvas({ |
| 745 | 744 | // 如果按住空格,禁用 canvas 内部的 pointer-events 以便拖动容器 |
| 746 | 745 | pointerEvents: isSpacePressed ? 'none' : 'auto' |
| 747 | 746 | }} |
| 747 | + onClick={(e) => { | |
| 748 | + // 点击画布空白处取消选中 | |
| 749 | + const target = e.target as HTMLElement; | |
| 750 | + const isOnElement = target.closest('[id^="element-"]'); | |
| 751 | + const isOnPaperResize = target.closest('[title*="拖拽拉高"]') || target.closest('[title*="拖拽拉宽"]'); | |
| 752 | + if (!isOnElement && !isOnPaperResize) { | |
| 753 | + onSelect(null); | |
| 754 | + } | |
| 755 | + }} | |
| 748 | 756 | onPointerDown={(e) => { |
| 749 | 757 | // 空白处或标尺等非控件区域按下即开始平移(放宽判定:在画布内且未点到元素/纸张拖拽条) |
| 750 | 758 | const target = e.target as HTMLElement; |
| ... | ... | @@ -752,22 +760,25 @@ export function LabelCanvas({ |
| 752 | 760 | const isOnPaperResize = target.closest('[title*="拖拽拉高"]') || target.closest('[title*="拖拽拉宽"]'); |
| 753 | 761 | const isOnCanvasArea = canvasRef.current?.contains(target); |
| 754 | 762 | if (isOnCanvasArea && !isOnElement && !isOnPaperResize && !dragRef.current && !resizeRef.current) { |
| 755 | - e.preventDefault(); | |
| 756 | - e.stopPropagation(); | |
| 757 | - setIsPanning(true); | |
| 758 | - panOffsetStartRef.current = { | |
| 759 | - x: panOffset.x, | |
| 760 | - y: panOffset.y, | |
| 761 | - startX: e.clientX, | |
| 762 | - startY: e.clientY, | |
| 763 | - }; | |
| 764 | - panStartRef.current = { | |
| 765 | - x: e.clientX, | |
| 766 | - y: e.clientY, | |
| 767 | - scrollLeft: scrollContainerRef.current?.scrollLeft ?? 0, | |
| 768 | - scrollTop: scrollContainerRef.current?.scrollTop ?? 0, | |
| 769 | - }; | |
| 770 | - (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); | |
| 763 | + // 如果按住空格或中键,开始平移 | |
| 764 | + if (isSpacePressed || e.button === 1) { | |
| 765 | + e.preventDefault(); | |
| 766 | + e.stopPropagation(); | |
| 767 | + setIsPanning(true); | |
| 768 | + panOffsetStartRef.current = { | |
| 769 | + x: panOffset.x, | |
| 770 | + y: panOffset.y, | |
| 771 | + startX: e.clientX, | |
| 772 | + startY: e.clientY, | |
| 773 | + }; | |
| 774 | + panStartRef.current = { | |
| 775 | + x: e.clientX, | |
| 776 | + y: e.clientY, | |
| 777 | + scrollLeft: scrollContainerRef.current?.scrollLeft ?? 0, | |
| 778 | + scrollTop: scrollContainerRef.current?.scrollTop ?? 0, | |
| 779 | + }; | |
| 780 | + (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); | |
| 781 | + } | |
| 771 | 782 | } |
| 772 | 783 | }} |
| 773 | 784 | onPointerMove={handlePointerMove} | ... | ... |
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/PropertiesPanel.tsx
| ... | ... | @@ -276,12 +276,20 @@ function ElementConfigFields({ |
| 276 | 276 | <div> |
| 277 | 277 | <Label className="text-xs">Text</Label> |
| 278 | 278 | <Input |
| 279 | - value={(cfg.text as string) ?? ''} | |
| 279 | + value={(cfg.text as string) ?? '0.00'} | |
| 280 | 280 | onChange={(e) => update('text', e.target.value)} |
| 281 | 281 | className="h-8 text-sm mt-1" |
| 282 | 282 | /> |
| 283 | 283 | </div> |
| 284 | 284 | <div> |
| 285 | + <Label className="text-xs">Prefix</Label> | |
| 286 | + <Input | |
| 287 | + value={(cfg.prefix as string) ?? '¥'} | |
| 288 | + onChange={(e) => update('prefix', e.target.value)} | |
| 289 | + className="h-8 text-sm mt-1" | |
| 290 | + /> | |
| 291 | + </div> | |
| 292 | + <div> | |
| 285 | 293 | <Label className="text-xs">Font Size</Label> |
| 286 | 294 | <Input |
| 287 | 295 | type="number" |
| ... | ... | @@ -293,7 +301,7 @@ function ElementConfigFields({ |
| 293 | 301 | <div> |
| 294 | 302 | <Label className="text-xs">Text Align</Label> |
| 295 | 303 | <Select |
| 296 | - value={(cfg.textAlign as string) ?? 'left'} | |
| 304 | + value={(cfg.textAlign as string) ?? 'right'} | |
| 297 | 305 | onValueChange={(v) => update('textAlign', v)} |
| 298 | 306 | > |
| 299 | 307 | <SelectTrigger className="h-8 text-sm mt-1"> |
| ... | ... | @@ -314,7 +322,7 @@ function ElementConfigFields({ |
| 314 | 322 | <div> |
| 315 | 323 | <Label className="text-xs">Data</Label> |
| 316 | 324 | <Input |
| 317 | - value={(cfg.data as string) ?? ''} | |
| 325 | + value={(cfg.data as string) ?? '123456789'} | |
| 318 | 326 | onChange={(e) => update('data', e.target.value)} |
| 319 | 327 | className="h-8 text-sm mt-1" |
| 320 | 328 | /> |
| ... | ... | @@ -334,6 +342,13 @@ function ElementConfigFields({ |
| 334 | 342 | </SelectContent> |
| 335 | 343 | </Select> |
| 336 | 344 | </div> |
| 345 | + <div className="flex items-center gap-2"> | |
| 346 | + <Switch | |
| 347 | + checked={(cfg.showText as boolean) !== false} | |
| 348 | + onCheckedChange={(v) => update('showText', v)} | |
| 349 | + /> | |
| 350 | + <Label className="text-xs">Show Text</Label> | |
| 351 | + </div> | |
| 337 | 352 | </> |
| 338 | 353 | ); |
| 339 | 354 | case 'QRCODE': |
| ... | ... | @@ -341,24 +356,207 @@ function ElementConfigFields({ |
| 341 | 356 | <div> |
| 342 | 357 | <Label className="text-xs">Data (URL)</Label> |
| 343 | 358 | <Input |
| 344 | - value={(cfg.data as string) ?? ''} | |
| 359 | + value={(cfg.data as string) ?? 'https://example.com'} | |
| 345 | 360 | onChange={(e) => update('data', e.target.value)} |
| 346 | 361 | className="h-8 text-sm mt-1" |
| 347 | 362 | /> |
| 348 | 363 | </div> |
| 349 | 364 | ); |
| 350 | - case 'WEIGHT': | |
| 365 | + case 'IMAGE': | |
| 366 | + return ( | |
| 367 | + <> | |
| 368 | + <div> | |
| 369 | + <Label className="text-xs">Image URL</Label> | |
| 370 | + <Input | |
| 371 | + value={(cfg.src as string) ?? ''} | |
| 372 | + onChange={(e) => update('src', e.target.value)} | |
| 373 | + className="h-8 text-sm mt-1" | |
| 374 | + placeholder="输入图片URL或路径" | |
| 375 | + /> | |
| 376 | + </div> | |
| 377 | + <div> | |
| 378 | + <Label className="text-xs">Scale Mode</Label> | |
| 379 | + <Select | |
| 380 | + value={(cfg.scaleMode as string) ?? 'contain'} | |
| 381 | + onValueChange={(v) => update('scaleMode', v)} | |
| 382 | + > | |
| 383 | + <SelectTrigger className="h-8 text-sm mt-1"> | |
| 384 | + <SelectValue /> | |
| 385 | + </SelectTrigger> | |
| 386 | + <SelectContent> | |
| 387 | + <SelectItem value="contain">Contain</SelectItem> | |
| 388 | + <SelectItem value="cover">Cover</SelectItem> | |
| 389 | + <SelectItem value="fill">Fill</SelectItem> | |
| 390 | + </SelectContent> | |
| 391 | + </Select> | |
| 392 | + </div> | |
| 393 | + </> | |
| 394 | + ); | |
| 395 | + case 'DATE': | |
| 396 | + return ( | |
| 397 | + <> | |
| 398 | + <div> | |
| 399 | + <Label className="text-xs">Format</Label> | |
| 400 | + <Input | |
| 401 | + value={(cfg.format as string) ?? 'YYYY-MM-DD'} | |
| 402 | + onChange={(e) => update('format', e.target.value)} | |
| 403 | + className="h-8 text-sm mt-1" | |
| 404 | + placeholder="YYYY-MM-DD" | |
| 405 | + /> | |
| 406 | + </div> | |
| 407 | + <div> | |
| 408 | + <Label className="text-xs">Offset Days</Label> | |
| 409 | + <Input | |
| 410 | + type="number" | |
| 411 | + value={(cfg.offsetDays as number) ?? 0} | |
| 412 | + onChange={(e) => update('offsetDays', Number(e.target.value) || 0)} | |
| 413 | + className="h-8 text-sm mt-1" | |
| 414 | + /> | |
| 415 | + </div> | |
| 416 | + </> | |
| 417 | + ); | |
| 418 | + case 'TIME': | |
| 351 | 419 | return ( |
| 352 | 420 | <div> |
| 353 | - <Label className="text-xs">Value</Label> | |
| 421 | + <Label className="text-xs">Format</Label> | |
| 354 | 422 | <Input |
| 355 | - type="number" | |
| 356 | - value={(cfg.value as number) ?? 0} | |
| 357 | - onChange={(e) => update('value', Number(e.target.value) || 0)} | |
| 423 | + value={(cfg.format as string) ?? 'HH:mm'} | |
| 424 | + onChange={(e) => update('format', e.target.value)} | |
| 358 | 425 | className="h-8 text-sm mt-1" |
| 426 | + placeholder="HH:mm" | |
| 359 | 427 | /> |
| 360 | 428 | </div> |
| 361 | 429 | ); |
| 430 | + case 'DURATION': | |
| 431 | + return ( | |
| 432 | + <> | |
| 433 | + <div> | |
| 434 | + <Label className="text-xs">Format</Label> | |
| 435 | + <Input | |
| 436 | + value={(cfg.format as string) ?? 'YYYY-MM-DD'} | |
| 437 | + onChange={(e) => update('format', e.target.value)} | |
| 438 | + className="h-8 text-sm mt-1" | |
| 439 | + placeholder="YYYY-MM-DD" | |
| 440 | + /> | |
| 441 | + </div> | |
| 442 | + <div> | |
| 443 | + <Label className="text-xs">Offset Days</Label> | |
| 444 | + <Input | |
| 445 | + type="number" | |
| 446 | + value={(cfg.offsetDays as number) ?? 3} | |
| 447 | + onChange={(e) => update('offsetDays', Number(e.target.value) || 3)} | |
| 448 | + className="h-8 text-sm mt-1" | |
| 449 | + /> | |
| 450 | + </div> | |
| 451 | + </> | |
| 452 | + ); | |
| 453 | + case 'WEIGHT': | |
| 454 | + return ( | |
| 455 | + <> | |
| 456 | + <div> | |
| 457 | + <Label className="text-xs">Value</Label> | |
| 458 | + <Input | |
| 459 | + type="number" | |
| 460 | + value={(cfg.value as number) ?? 500} | |
| 461 | + onChange={(e) => update('value', Number(e.target.value) || 0)} | |
| 462 | + className="h-8 text-sm mt-1" | |
| 463 | + /> | |
| 464 | + </div> | |
| 465 | + <div> | |
| 466 | + <Label className="text-xs">Unit</Label> | |
| 467 | + <Select | |
| 468 | + value={(cfg.unit as string) ?? 'g'} | |
| 469 | + onValueChange={(v) => update('unit', v)} | |
| 470 | + > | |
| 471 | + <SelectTrigger className="h-8 text-sm mt-1"> | |
| 472 | + <SelectValue /> | |
| 473 | + </SelectTrigger> | |
| 474 | + <SelectContent> | |
| 475 | + <SelectItem value="g">g</SelectItem> | |
| 476 | + <SelectItem value="kg">kg</SelectItem> | |
| 477 | + <SelectItem value="oz">oz</SelectItem> | |
| 478 | + <SelectItem value="lb">lb</SelectItem> | |
| 479 | + </SelectContent> | |
| 480 | + </Select> | |
| 481 | + </div> | |
| 482 | + </> | |
| 483 | + ); | |
| 484 | + case 'WEIGHT_PRICE': | |
| 485 | + return ( | |
| 486 | + <> | |
| 487 | + <div> | |
| 488 | + <Label className="text-xs">Unit Price</Label> | |
| 489 | + <Input | |
| 490 | + type="number" | |
| 491 | + value={(cfg.unitPrice as number) ?? 10} | |
| 492 | + onChange={(e) => update('unitPrice', Number(e.target.value) || 0)} | |
| 493 | + className="h-8 text-sm mt-1" | |
| 494 | + /> | |
| 495 | + </div> | |
| 496 | + <div> | |
| 497 | + <Label className="text-xs">Weight</Label> | |
| 498 | + <Input | |
| 499 | + type="number" | |
| 500 | + step="0.1" | |
| 501 | + value={(cfg.weight as number) ?? 0.5} | |
| 502 | + onChange={(e) => update('weight', Number(e.target.value) || 0)} | |
| 503 | + className="h-8 text-sm mt-1" | |
| 504 | + /> | |
| 505 | + </div> | |
| 506 | + <div> | |
| 507 | + <Label className="text-xs">Currency</Label> | |
| 508 | + <Input | |
| 509 | + value={(cfg.currency as string) ?? '¥'} | |
| 510 | + onChange={(e) => update('currency', e.target.value)} | |
| 511 | + className="h-8 text-sm mt-1" | |
| 512 | + /> | |
| 513 | + </div> | |
| 514 | + </> | |
| 515 | + ); | |
| 516 | + case 'NUTRITION': | |
| 517 | + return ( | |
| 518 | + <> | |
| 519 | + <div> | |
| 520 | + <Label className="text-xs">Calories</Label> | |
| 521 | + <Input | |
| 522 | + type="number" | |
| 523 | + value={(cfg.calories as number) ?? 120} | |
| 524 | + onChange={(e) => update('calories', Number(e.target.value) || 0)} | |
| 525 | + className="h-8 text-sm mt-1" | |
| 526 | + /> | |
| 527 | + </div> | |
| 528 | + <div> | |
| 529 | + <Label className="text-xs">Fat</Label> | |
| 530 | + <Input | |
| 531 | + value={(cfg.fat as string) ?? '5g'} | |
| 532 | + onChange={(e) => update('fat', e.target.value)} | |
| 533 | + className="h-8 text-sm mt-1" | |
| 534 | + /> | |
| 535 | + </div> | |
| 536 | + <div> | |
| 537 | + <Label className="text-xs">Protein</Label> | |
| 538 | + <Input | |
| 539 | + value={(cfg.protein as string) ?? '3g'} | |
| 540 | + onChange={(e) => update('protein', e.target.value)} | |
| 541 | + className="h-8 text-sm mt-1" | |
| 542 | + /> | |
| 543 | + </div> | |
| 544 | + <div> | |
| 545 | + <Label className="text-xs">Carbs</Label> | |
| 546 | + <Input | |
| 547 | + value={(cfg.carbs as string) ?? '10g'} | |
| 548 | + onChange={(e) => update('carbs', e.target.value)} | |
| 549 | + className="h-8 text-sm mt-1" | |
| 550 | + /> | |
| 551 | + </div> | |
| 552 | + </> | |
| 553 | + ); | |
| 554 | + case 'BLANK': | |
| 555 | + return ( | |
| 556 | + <div className="text-xs text-gray-500"> | |
| 557 | + 空白占位元素,无需配置 | |
| 558 | + </div> | |
| 559 | + ); | |
| 362 | 560 | default: |
| 363 | 561 | return ( |
| 364 | 562 | <div className="text-xs text-gray-500"> | ... | ... |
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/index.tsx
| ... | ... | @@ -47,7 +47,69 @@ export function LabelTemplateEditor({ |
| 47 | 47 | type: Parameters<typeof createDefaultElement>[0], |
| 48 | 48 | configOverride?: Partial<Record<string, unknown>> |
| 49 | 49 | ) => { |
| 50 | - const el = createDefaultElement(type, 30, 30); | |
| 50 | + // 计算画布中心位置(像素) | |
| 51 | + const unitToPx = (value: number, unit: 'cm' | 'inch'): number => { | |
| 52 | + return unit === 'cm' ? value * 37.8 : value * 96; | |
| 53 | + }; | |
| 54 | + | |
| 55 | + const canvasWidthPx = unitToPx(template.width, template.unit); | |
| 56 | + const canvasHeightPx = unitToPx(template.height, template.unit); | |
| 57 | + | |
| 58 | + // 创建默认元素以获取其尺寸 | |
| 59 | + const tempEl = createDefaultElement(type, 0, 0); | |
| 60 | + | |
| 61 | + // 对齐到网格 | |
| 62 | + const GRID_SIZE = 8; | |
| 63 | + const snapToGrid = (value: number): number => { | |
| 64 | + return Math.round(value / GRID_SIZE) * GRID_SIZE; | |
| 65 | + }; | |
| 66 | + | |
| 67 | + // 计算元素中心对齐到画布中心的位置 | |
| 68 | + let centerX = (canvasWidthPx - tempEl.width) / 2; | |
| 69 | + let centerY = (canvasHeightPx - tempEl.height) / 2; | |
| 70 | + | |
| 71 | + // 检查是否与现有元素重叠,如果重叠则尝试偏移 | |
| 72 | + const checkOverlap = (x: number, y: number, width: number, height: number): boolean => { | |
| 73 | + return template.elements.some((el) => { | |
| 74 | + const elRight = el.x + el.width; | |
| 75 | + const elBottom = el.y + el.height; | |
| 76 | + const newRight = x + width; | |
| 77 | + const newBottom = y + height; | |
| 78 | + return !(x >= elRight || newRight <= el.x || y >= elBottom || newBottom <= el.y); | |
| 79 | + }); | |
| 80 | + }; | |
| 81 | + | |
| 82 | + // 如果中心位置重叠,尝试在周围寻找空位 | |
| 83 | + if (checkOverlap(centerX, centerY, tempEl.width, tempEl.height)) { | |
| 84 | + const offset = GRID_SIZE * 2; | |
| 85 | + let found = false; | |
| 86 | + // 尝试右下方 | |
| 87 | + for (let tryY = centerY; tryY < canvasHeightPx - tempEl.height && !found; tryY += offset) { | |
| 88 | + for (let tryX = centerX; tryX < canvasWidthPx - tempEl.width && !found; tryX += offset) { | |
| 89 | + if (!checkOverlap(tryX, tryY, tempEl.width, tempEl.height)) { | |
| 90 | + centerX = tryX; | |
| 91 | + centerY = tryY; | |
| 92 | + found = true; | |
| 93 | + break; | |
| 94 | + } | |
| 95 | + } | |
| 96 | + } | |
| 97 | + // 如果还没找到,尝试左上方 | |
| 98 | + if (!found) { | |
| 99 | + for (let tryY = centerY; tryY >= 0 && !found; tryY -= offset) { | |
| 100 | + for (let tryX = centerX; tryX >= 0 && !found; tryX -= offset) { | |
| 101 | + if (!checkOverlap(tryX, tryY, tempEl.width, tempEl.height)) { | |
| 102 | + centerX = tryX; | |
| 103 | + centerY = tryY; | |
| 104 | + found = true; | |
| 105 | + break; | |
| 106 | + } | |
| 107 | + } | |
| 108 | + } | |
| 109 | + } | |
| 110 | + } | |
| 111 | + | |
| 112 | + const el = createDefaultElement(type, Math.max(0, snapToGrid(centerX)), Math.max(0, snapToGrid(centerY))); | |
| 51 | 113 | if (configOverride && Object.keys(configOverride).length > 0) { |
| 52 | 114 | el.config = { ...el.config, ...configOverride }; |
| 53 | 115 | } |
| ... | ... | @@ -56,7 +118,7 @@ export function LabelTemplateEditor({ |
| 56 | 118 | elements: [...prev.elements, el], |
| 57 | 119 | })); |
| 58 | 120 | setSelectedId(el.id); |
| 59 | - }, []); | |
| 121 | + }, [template.width, template.height, template.unit, template.elements]); | |
| 60 | 122 | |
| 61 | 123 | const updateElement = useCallback((id: string, patch: Partial<LabelElement>) => { |
| 62 | 124 | setTemplate((prev) => ({ | ... | ... |
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplatesView.tsx
美国版/Food Labeling Management Platform/src/components/people/PeopleView.tsx
| ... | ... | @@ -59,11 +59,21 @@ import { cn } from "../ui/utils"; |
| 59 | 59 | import { toast } from "sonner"; |
| 60 | 60 | |
| 61 | 61 | import { getRbacMenuTree } from "../../services/systemMenuService"; |
| 62 | -import { deleteRoleMenus, getRoleMenuIds, setRoleMenus } from "../../services/rbacRoleMenuService"; | |
| 62 | +import { deleteRoleMenus, setRoleMenus } from "../../services/rbacRoleMenuService"; | |
| 63 | 63 | import type { RbacMenuTreeNode } from "../../types/systemMenu"; |
| 64 | 64 | import { getRoles } from "../../services/roleService"; |
| 65 | -import { createRbacRole, deleteRbacRole, updateRbacRole } from "../../services/rbacRoleService"; | |
| 65 | +import { createRbacRole, deleteRbacRole, getRbacRoleMenuIds, updateRbacRole } from "../../services/rbacRoleService"; | |
| 66 | 66 | import type { RoleDto } from "../../types/role"; |
| 67 | +import { getLocations } from "../../services/locationService"; | |
| 68 | +import { | |
| 69 | + createTeamMember, | |
| 70 | + deleteTeamMember, | |
| 71 | + getTeamMemberById, | |
| 72 | + getTeamMembers, | |
| 73 | + updateTeamMember, | |
| 74 | +} from "../../services/teamMemberService"; | |
| 75 | +import type { LocationDto } from "../../types/location"; | |
| 76 | +import type { TeamMemberCreateInput, TeamMemberDto, TeamMemberUpdateInput } from "../../types/teamMember"; | |
| 67 | 77 | |
| 68 | 78 | // --- Mock Data --- |
| 69 | 79 | |
| ... | ... | @@ -161,13 +171,31 @@ export function PeopleView() { |
| 161 | 171 | const [debouncedRoleKeyword, setDebouncedRoleKeyword] = useState(""); |
| 162 | 172 | const [partners, setPartners] = useState(MOCK_PARTNERS); |
| 163 | 173 | const [groups, setGroups] = useState(MOCK_GROUPS); |
| 164 | - const [members, setMembers] = useState(MOCK_MEMBERS); | |
| 174 | + | |
| 175 | + const [members, setMembers] = useState<TeamMemberDto[]>([]); | |
| 176 | + const [membersLoading, setMembersLoading] = useState(false); | |
| 177 | + const [memberTotal, setMemberTotal] = useState(0); | |
| 178 | + const [memberRefreshSeq, setMemberRefreshSeq] = useState(0); | |
| 179 | + const [memberPageIndex, setMemberPageIndex] = useState(1); | |
| 180 | + const [memberPageSize, setMemberPageSize] = useState(10); | |
| 181 | + const memberTotalPages = Math.max(1, Math.ceil(memberTotal / memberPageSize)); | |
| 182 | + const membersAbortRef = useRef<AbortController | null>(null); | |
| 183 | + | |
| 184 | + const [memberKeyword, setMemberKeyword] = useState(""); | |
| 185 | + const memberKeywordTimerRef = useRef<number | null>(null); | |
| 186 | + const [debouncedMemberKeyword, setDebouncedMemberKeyword] = useState(""); | |
| 187 | + | |
| 188 | + const [editingMember, setEditingMember] = useState<TeamMemberDto | null>(null); | |
| 189 | + const [isDeleteMemberDialogOpen, setIsDeleteMemberDialogOpen] = useState(false); | |
| 190 | + const [deletingMember, setDeletingMember] = useState<TeamMemberDto | null>(null); | |
| 165 | 191 | |
| 166 | 192 | // Dialog States |
| 167 | 193 | const [isRoleDialogOpen, setIsRoleDialogOpen] = useState(false); |
| 168 | 194 | const [editingRole, setEditingRole] = useState<RoleDto | null>(null); |
| 169 | 195 | const [isRoleMenuDialogOpen, setIsRoleMenuDialogOpen] = useState(false); |
| 170 | 196 | const [menuRole, setMenuRole] = useState<RoleDto | null>(null); |
| 197 | + const [isDeleteRoleDialogOpen, setIsDeleteRoleDialogOpen] = useState(false); | |
| 198 | + const [deletingRole, setDeletingRole] = useState<RoleDto | null>(null); | |
| 171 | 199 | const [isPartnerDialogOpen, setIsPartnerDialogOpen] = useState(false); |
| 172 | 200 | const [isGroupDialogOpen, setIsGroupDialogOpen] = useState(false); |
| 173 | 201 | const [isMemberDialogOpen, setIsMemberDialogOpen] = useState(false); |
| ... | ... | @@ -186,10 +214,22 @@ export function PeopleView() { |
| 186 | 214 | }, [roleKeyword]); |
| 187 | 215 | |
| 188 | 216 | useEffect(() => { |
| 217 | + if (memberKeywordTimerRef.current) window.clearTimeout(memberKeywordTimerRef.current); | |
| 218 | + memberKeywordTimerRef.current = window.setTimeout(() => setDebouncedMemberKeyword(memberKeyword.trim()), 300); | |
| 219 | + return () => { | |
| 220 | + if (memberKeywordTimerRef.current) window.clearTimeout(memberKeywordTimerRef.current); | |
| 221 | + }; | |
| 222 | + }, [memberKeyword]); | |
| 223 | + | |
| 224 | + useEffect(() => { | |
| 189 | 225 | setRolePageIndex(1); |
| 190 | 226 | }, [debouncedRoleKeyword, rolePageSize]); |
| 191 | 227 | |
| 192 | 228 | useEffect(() => { |
| 229 | + setMemberPageIndex(1); | |
| 230 | + }, [debouncedMemberKeyword, memberPageSize]); | |
| 231 | + | |
| 232 | + useEffect(() => { | |
| 193 | 233 | if (activeTab !== "Roles") return; |
| 194 | 234 | const run = async () => { |
| 195 | 235 | rolesAbortRef.current?.abort(); |
| ... | ... | @@ -224,6 +264,41 @@ export function PeopleView() { |
| 224 | 264 | return () => rolesAbortRef.current?.abort(); |
| 225 | 265 | }, [activeTab, debouncedRoleKeyword, rolePageIndex, rolePageSize, roleRefreshSeq]); |
| 226 | 266 | |
| 267 | + useEffect(() => { | |
| 268 | + if (activeTab !== "Team Member") return; | |
| 269 | + const run = async () => { | |
| 270 | + membersAbortRef.current?.abort(); | |
| 271 | + const ac = new AbortController(); | |
| 272 | + membersAbortRef.current = ac; | |
| 273 | + | |
| 274 | + setMembersLoading(true); | |
| 275 | + try { | |
| 276 | + const res = await getTeamMembers( | |
| 277 | + { | |
| 278 | + skipCount: Math.max(1, memberPageIndex), | |
| 279 | + maxResultCount: memberPageSize, | |
| 280 | + keyword: debouncedMemberKeyword || undefined, | |
| 281 | + }, | |
| 282 | + ac.signal, | |
| 283 | + ); | |
| 284 | + setMembers(res.items ?? []); | |
| 285 | + setMemberTotal(res.totalCount ?? 0); | |
| 286 | + } catch (e: any) { | |
| 287 | + if (e?.name === "AbortError") return; | |
| 288 | + toast.error("Failed to load team members.", { | |
| 289 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 290 | + }); | |
| 291 | + setMembers([]); | |
| 292 | + setMemberTotal(0); | |
| 293 | + } finally { | |
| 294 | + setMembersLoading(false); | |
| 295 | + } | |
| 296 | + }; | |
| 297 | + | |
| 298 | + run(); | |
| 299 | + return () => membersAbortRef.current?.abort(); | |
| 300 | + }, [activeTab, debouncedMemberKeyword, memberPageIndex, memberPageSize, memberRefreshSeq]); | |
| 301 | + | |
| 227 | 302 | const openCreateDialog = () => { |
| 228 | 303 | switch (activeTab) { |
| 229 | 304 | case 'Roles': |
| ... | ... | @@ -232,7 +307,7 @@ export function PeopleView() { |
| 232 | 307 | break; |
| 233 | 308 | case 'Partner': setIsPartnerDialogOpen(true); break; |
| 234 | 309 | case 'Group': setIsGroupDialogOpen(true); break; |
| 235 | - case 'Team Member': setIsMemberDialogOpen(true); break; | |
| 310 | + case 'Team Member': setEditingMember(null); setIsMemberDialogOpen(true); break; | |
| 236 | 311 | } |
| 237 | 312 | }; |
| 238 | 313 | |
| ... | ... | @@ -245,9 +320,10 @@ export function PeopleView() { |
| 245 | 320 | <div className="flex flex-nowrap items-center gap-3"> |
| 246 | 321 | <Input |
| 247 | 322 | placeholder="Search" |
| 248 | - value={activeTab === "Roles" ? roleKeyword : ""} | |
| 323 | + value={activeTab === "Roles" ? roleKeyword : activeTab === "Team Member" ? memberKeyword : ""} | |
| 249 | 324 | onChange={(e) => { |
| 250 | 325 | if (activeTab === "Roles") setRoleKeyword(e.target.value); |
| 326 | + if (activeTab === "Team Member") setMemberKeyword(e.target.value); | |
| 251 | 327 | }} |
| 252 | 328 | style={{ height: 40, boxSizing: 'border-box' }} |
| 253 | 329 | className="border border-gray-300 rounded-md w-40 shrink-0 bg-white placeholder:text-gray-500" |
| ... | ... | @@ -357,17 +433,8 @@ export function PeopleView() { |
| 357 | 433 | variant="ghost" |
| 358 | 434 | size="sm" |
| 359 | 435 | onClick={async () => { |
| 360 | - const ok = window.confirm(`Delete role "${r.roleName ?? r.id}"? This cannot be undone.`); | |
| 361 | - if (!ok) return; | |
| 362 | - try { | |
| 363 | - await deleteRbacRole(r.id); | |
| 364 | - toast.success("Role deleted.", { description: "The role has been removed successfully." }); | |
| 365 | - setRoleRefreshSeq((x) => x + 1); | |
| 366 | - } catch (e: any) { | |
| 367 | - toast.error("Failed to delete role.", { | |
| 368 | - description: e?.message ? String(e.message) : "Please try again.", | |
| 369 | - }); | |
| 370 | - } | |
| 436 | + setDeletingRole(r); | |
| 437 | + setIsDeleteRoleDialogOpen(true); | |
| 371 | 438 | }} |
| 372 | 439 | title="Delete role" |
| 373 | 440 | > |
| ... | ... | @@ -502,7 +569,8 @@ export function PeopleView() { |
| 502 | 569 | |
| 503 | 570 | case 'Team Member': |
| 504 | 571 | return ( |
| 505 | - <Table> | |
| 572 | + <> | |
| 573 | + <Table> | |
| 506 | 574 | <TableHeader> |
| 507 | 575 | <TableRow className="bg-gray-100"> |
| 508 | 576 | <TableHead className="font-bold text-black border-r">Name</TableHead> |
| ... | ... | @@ -515,35 +583,134 @@ export function PeopleView() { |
| 515 | 583 | </TableRow> |
| 516 | 584 | </TableHeader> |
| 517 | 585 | <TableBody> |
| 518 | - {members.map(m => ( | |
| 519 | - <TableRow key={m.id}> | |
| 520 | - <TableCell className="font-medium border-r">{m.name}</TableCell> | |
| 521 | - <TableCell className="border-r text-gray-600">{m.email}</TableCell> | |
| 522 | - <TableCell className="border-r text-gray-600">{m.phone}</TableCell> | |
| 523 | - <TableCell className="border-r"> | |
| 524 | - <Badge variant="outline" className="font-normal">{m.role}</Badge> | |
| 586 | + {membersLoading ? ( | |
| 587 | + <TableRow> | |
| 588 | + <TableCell colSpan={7} className="text-center text-sm text-gray-500 py-10"> | |
| 589 | + Loading... | |
| 525 | 590 | </TableCell> |
| 526 | - <TableCell className="border-r"> | |
| 527 | - <div className="flex flex-col gap-1"> | |
| 528 | - {m.locations.map(loc => ( | |
| 529 | - <div key={loc} className="flex items-center gap-1 text-xs text-gray-600"> | |
| 530 | - <MapPin className="w-3 h-3" /> {loc} | |
| 531 | - </div> | |
| 532 | - ))} | |
| 533 | - </div> | |
| 534 | - </TableCell> | |
| 535 | - <TableCell className="border-r"> | |
| 536 | - <Badge className={m.status === 'active' ? "bg-green-600" : "bg-gray-400"}> | |
| 537 | - {m.status} | |
| 538 | - </Badge> | |
| 539 | - </TableCell> | |
| 540 | - <TableCell className="text-center"> | |
| 541 | - <Button variant="ghost" size="sm"><Edit className="w-4 h-4 text-gray-500" /></Button> | |
| 591 | + </TableRow> | |
| 592 | + ) : members.length === 0 ? ( | |
| 593 | + <TableRow> | |
| 594 | + <TableCell colSpan={7} className="text-center text-sm text-gray-500 py-10"> | |
| 595 | + No results. | |
| 542 | 596 | </TableCell> |
| 543 | 597 | </TableRow> |
| 544 | - ))} | |
| 598 | + ) : ( | |
| 599 | + members.map((m) => ( | |
| 600 | + <TableRow key={m.id}> | |
| 601 | + <TableCell className="font-medium border-r">{m.fullName ?? m.userName ?? "N/A"}</TableCell> | |
| 602 | + <TableCell className="border-r text-gray-600">{m.email ?? "N/A"}</TableCell> | |
| 603 | + <TableCell className="border-r text-gray-600">{m.phone ?? "N/A"}</TableCell> | |
| 604 | + <TableCell className="border-r"> | |
| 605 | + <Badge variant="outline" className="font-normal"> | |
| 606 | + {m.roleName ?? m.roleId ?? "N/A"} | |
| 607 | + </Badge> | |
| 608 | + </TableCell> | |
| 609 | + <TableCell className="border-r"> | |
| 610 | + <div className="flex flex-col gap-1"> | |
| 611 | + {(m.locations?.length ? m.locations : m.locationIds ?? []).map((loc) => ( | |
| 612 | + <div key={loc} className="flex items-center gap-1 text-xs text-gray-600"> | |
| 613 | + <MapPin className="w-3 h-3" /> {loc} | |
| 614 | + </div> | |
| 615 | + ))} | |
| 616 | + {(!m.locations?.length && !(m.locationIds?.length ?? 0)) && ( | |
| 617 | + <div className="text-xs text-gray-500">无</div> | |
| 618 | + )} | |
| 619 | + </div> | |
| 620 | + </TableCell> | |
| 621 | + <TableCell className="border-r"> | |
| 622 | + <Badge className={m.state ? "bg-green-600" : "bg-gray-400"}> | |
| 623 | + {m.state ? "Active" : "Inactive"} | |
| 624 | + </Badge> | |
| 625 | + </TableCell> | |
| 626 | + <TableCell className="text-center"> | |
| 627 | + <div className="flex items-center justify-center gap-2"> | |
| 628 | + <Button | |
| 629 | + variant="ghost" | |
| 630 | + size="sm" | |
| 631 | + onClick={() => { | |
| 632 | + setEditingMember(m); | |
| 633 | + setIsMemberDialogOpen(true); | |
| 634 | + }} | |
| 635 | + title="Edit" | |
| 636 | + > | |
| 637 | + <Edit className="w-4 h-4 text-gray-500" /> | |
| 638 | + </Button> | |
| 639 | + <Button | |
| 640 | + variant="ghost" | |
| 641 | + size="sm" | |
| 642 | + onClick={() => { | |
| 643 | + setDeletingMember(m); | |
| 644 | + setIsDeleteMemberDialogOpen(true); | |
| 645 | + }} | |
| 646 | + title="Delete" | |
| 647 | + > | |
| 648 | + <Trash2 className="w-4 h-4 text-red-600" /> | |
| 649 | + </Button> | |
| 650 | + </div> | |
| 651 | + </TableCell> | |
| 652 | + </TableRow> | |
| 653 | + )) | |
| 654 | + )} | |
| 545 | 655 | </TableBody> |
| 546 | 656 | </Table> |
| 657 | + | |
| 658 | + <div className="px-4 py-3 border-t border-gray-200 bg-white flex flex-wrap items-center justify-between gap-3"> | |
| 659 | + <div className="text-sm text-gray-600"> | |
| 660 | + Showing {memberTotal === 0 ? 0 : (memberPageIndex - 1) * memberPageSize + 1}- | |
| 661 | + {Math.min(memberPageIndex * memberPageSize, memberTotal)} of {memberTotal} | |
| 662 | + </div> | |
| 663 | + | |
| 664 | + <div className="flex items-center gap-3"> | |
| 665 | + <Select value={String(memberPageSize)} onValueChange={(v) => setMemberPageSize(Number(v))}> | |
| 666 | + <SelectTrigger className="w-[110px] h-9 rounded-md border border-gray-300 bg-white text-gray-900"> | |
| 667 | + <SelectValue /> | |
| 668 | + </SelectTrigger> | |
| 669 | + <SelectContent> | |
| 670 | + {[10, 20, 50].map((n) => ( | |
| 671 | + <SelectItem key={n} value={String(n)}> | |
| 672 | + {n} / page | |
| 673 | + </SelectItem> | |
| 674 | + ))} | |
| 675 | + </SelectContent> | |
| 676 | + </Select> | |
| 677 | + | |
| 678 | + <Pagination className="mx-0 w-auto justify-end"> | |
| 679 | + <PaginationContent> | |
| 680 | + <PaginationItem> | |
| 681 | + <PaginationPrevious | |
| 682 | + href="#" | |
| 683 | + size="default" | |
| 684 | + onClick={(e) => { | |
| 685 | + e.preventDefault(); | |
| 686 | + setMemberPageIndex((p) => Math.max(1, p - 1)); | |
| 687 | + }} | |
| 688 | + aria-disabled={memberPageIndex <= 1} | |
| 689 | + className={memberPageIndex <= 1 ? "pointer-events-none opacity-50" : ""} | |
| 690 | + /> | |
| 691 | + </PaginationItem> | |
| 692 | + <PaginationItem> | |
| 693 | + <PaginationLink href="#" isActive size="default" onClick={(e) => e.preventDefault()}> | |
| 694 | + Page {memberPageIndex} / {memberTotalPages} | |
| 695 | + </PaginationLink> | |
| 696 | + </PaginationItem> | |
| 697 | + <PaginationItem> | |
| 698 | + <PaginationNext | |
| 699 | + href="#" | |
| 700 | + size="default" | |
| 701 | + onClick={(e) => { | |
| 702 | + e.preventDefault(); | |
| 703 | + setMemberPageIndex((p) => Math.min(memberTotalPages, p + 1)); | |
| 704 | + }} | |
| 705 | + aria-disabled={memberPageIndex >= memberTotalPages} | |
| 706 | + className={memberPageIndex >= memberTotalPages ? "pointer-events-none opacity-50" : ""} | |
| 707 | + /> | |
| 708 | + </PaginationItem> | |
| 709 | + </PaginationContent> | |
| 710 | + </Pagination> | |
| 711 | + </div> | |
| 712 | + </div> | |
| 713 | + </> | |
| 547 | 714 | ); |
| 548 | 715 | } |
| 549 | 716 | }; |
| ... | ... | @@ -579,9 +746,40 @@ export function PeopleView() { |
| 579 | 746 | if (!open) setMenuRole(null); |
| 580 | 747 | }} |
| 581 | 748 | /> |
| 749 | + <DeleteRoleDialog | |
| 750 | + open={isDeleteRoleDialogOpen} | |
| 751 | + role={deletingRole} | |
| 752 | + onOpenChange={(open) => { | |
| 753 | + setIsDeleteRoleDialogOpen(open); | |
| 754 | + if (!open) setDeletingRole(null); | |
| 755 | + }} | |
| 756 | + onDeleted={() => setRoleRefreshSeq((x) => x + 1)} | |
| 757 | + /> | |
| 582 | 758 | <CreatePartnerDialog open={isPartnerDialogOpen} onOpenChange={setIsPartnerDialogOpen} /> |
| 583 | 759 | <CreateGroupDialog open={isGroupDialogOpen} onOpenChange={setIsGroupDialogOpen} /> |
| 584 | - <CreateMemberDialog open={isMemberDialogOpen} onOpenChange={setIsMemberDialogOpen} roles={roles} /> | |
| 760 | + <MemberDialog | |
| 761 | + open={isMemberDialogOpen} | |
| 762 | + member={editingMember} | |
| 763 | + onOpenChange={(open) => { | |
| 764 | + setIsMemberDialogOpen(open); | |
| 765 | + if (!open) setEditingMember(null); | |
| 766 | + }} | |
| 767 | + onSaved={() => { | |
| 768 | + setMemberPageIndex(1); | |
| 769 | + setMemberRefreshSeq((x) => x + 1); | |
| 770 | + }} | |
| 771 | + /> | |
| 772 | + <DeleteMemberDialog | |
| 773 | + open={isDeleteMemberDialogOpen} | |
| 774 | + member={deletingMember} | |
| 775 | + onOpenChange={(open) => { | |
| 776 | + setIsDeleteMemberDialogOpen(open); | |
| 777 | + if (!open) setDeletingMember(null); | |
| 778 | + }} | |
| 779 | + onDeleted={() => { | |
| 780 | + setMemberRefreshSeq((x) => x + 1); | |
| 781 | + }} | |
| 782 | + /> | |
| 585 | 783 | </div> |
| 586 | 784 | ); |
| 587 | 785 | } |
| ... | ... | @@ -629,6 +827,7 @@ function RoleDialog({ |
| 629 | 827 | }; |
| 630 | 828 | |
| 631 | 829 | const submit = async () => { |
| 830 | + console.log("submit", role); | |
| 632 | 831 | if (!canSubmit) { |
| 633 | 832 | toast.error("Please fill in required fields.", { |
| 634 | 833 | description: "Role Name and Role Code are required.", |
| ... | ... | @@ -759,7 +958,7 @@ function RoleMenuPermissionsDialog({ |
| 759 | 958 | const tree = await getRbacMenuTree(ac.signal); |
| 760 | 959 | setMenuTree(tree ?? []); |
| 761 | 960 | if (roleId) { |
| 762 | - const checked = await getRoleMenuIds(roleId); | |
| 961 | + const checked = await getRbacRoleMenuIds(roleId, ac.signal); | |
| 763 | 962 | setSelectedIds(new Set(checked)); |
| 764 | 963 | } |
| 765 | 964 | } catch (e: any) { |
| ... | ... | @@ -911,6 +1110,7 @@ function RoleMenuPermissionsDialog({ |
| 911 | 1110 | }; |
| 912 | 1111 | |
| 913 | 1112 | const submit = async () => { |
| 1113 | + console.log("submit", role); | |
| 914 | 1114 | if (!roleId) return; |
| 915 | 1115 | setSubmitting(true); |
| 916 | 1116 | try { |
| ... | ... | @@ -1002,6 +1202,74 @@ function RoleMenuPermissionsDialog({ |
| 1002 | 1202 | ); |
| 1003 | 1203 | } |
| 1004 | 1204 | |
| 1205 | +function DeleteRoleDialog({ | |
| 1206 | + open, | |
| 1207 | + role, | |
| 1208 | + onOpenChange, | |
| 1209 | + onDeleted, | |
| 1210 | +}: { | |
| 1211 | + open: boolean; | |
| 1212 | + role: RoleDto | null; | |
| 1213 | + onOpenChange: (open: boolean) => void; | |
| 1214 | + onDeleted: () => void; | |
| 1215 | +}) { | |
| 1216 | + const [submitting, setSubmitting] = useState(false); | |
| 1217 | + | |
| 1218 | + const name = useMemo(() => { | |
| 1219 | + const n = (role?.roleName ?? "").trim(); | |
| 1220 | + return n || role?.roleCode || role?.id || "this role"; | |
| 1221 | + }, [role]); | |
| 1222 | + | |
| 1223 | + const submit = async () => { | |
| 1224 | + console.log("submit", role); | |
| 1225 | + if (!role?.id) return; | |
| 1226 | + setSubmitting(true); | |
| 1227 | + try { | |
| 1228 | + await deleteRbacRole(role.id); | |
| 1229 | + toast.success("Role deleted.", { | |
| 1230 | + description: "The role has been removed successfully.", | |
| 1231 | + }); | |
| 1232 | + onOpenChange(false); | |
| 1233 | + onDeleted(); | |
| 1234 | + } catch (e: any) { | |
| 1235 | + toast.error("Failed to delete role.", { | |
| 1236 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 1237 | + }); | |
| 1238 | + } finally { | |
| 1239 | + setSubmitting(false); | |
| 1240 | + } | |
| 1241 | + }; | |
| 1242 | + | |
| 1243 | + return ( | |
| 1244 | + <Dialog open={open} onOpenChange={onOpenChange}> | |
| 1245 | + <DialogContent className="sm:max-w-none" style={{ width: "30%" }}> | |
| 1246 | + <DialogHeader> | |
| 1247 | + <DialogTitle>Delete Role</DialogTitle> | |
| 1248 | + <DialogDescription>This action cannot be undone.</DialogDescription> | |
| 1249 | + </DialogHeader> | |
| 1250 | + | |
| 1251 | + <div className="text-sm text-gray-700"> | |
| 1252 | + Are you sure you want to delete <span className="font-medium">{name}</span>? | |
| 1253 | + </div> | |
| 1254 | + | |
| 1255 | + <DialogFooter className="flex-row flex-wrap justify-end"> | |
| 1256 | + <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}> | |
| 1257 | + Cancel | |
| 1258 | + </Button> | |
| 1259 | + <Button | |
| 1260 | + className="min-w-24" | |
| 1261 | + variant="destructive" | |
| 1262 | + disabled={submitting} | |
| 1263 | + onClick={submit} | |
| 1264 | + > | |
| 1265 | + {submitting ? "Deleting..." : "Delete"} | |
| 1266 | + </Button> | |
| 1267 | + </DialogFooter> | |
| 1268 | + </DialogContent> | |
| 1269 | + </Dialog> | |
| 1270 | + ); | |
| 1271 | +} | |
| 1272 | + | |
| 1005 | 1273 | function CreatePartnerDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { |
| 1006 | 1274 | return ( |
| 1007 | 1275 | <Dialog open={open} onOpenChange={onOpenChange}> |
| ... | ... | @@ -1071,70 +1339,474 @@ function CreateGroupDialog({ open, onOpenChange }: { open: boolean; onOpenChange |
| 1071 | 1339 | ); |
| 1072 | 1340 | } |
| 1073 | 1341 | |
| 1074 | -function CreateMemberDialog({ open, onOpenChange, roles }: { open: boolean; onOpenChange: (open: boolean) => void; roles: any[] }) { | |
| 1342 | +function MemberDialog({ | |
| 1343 | + open, | |
| 1344 | + member, | |
| 1345 | + onOpenChange, | |
| 1346 | + onSaved, | |
| 1347 | +}: { | |
| 1348 | + open: boolean; | |
| 1349 | + member: TeamMemberDto | null; | |
| 1350 | + onOpenChange: (open: boolean) => void; | |
| 1351 | + onSaved: () => void; | |
| 1352 | +}) { | |
| 1353 | + const isEdit = !!member?.id; | |
| 1354 | + const [submitting, setSubmitting] = useState(false); | |
| 1355 | + | |
| 1356 | + const [fullName, setFullName] = useState(""); | |
| 1357 | + const [userName, setUserName] = useState(""); | |
| 1358 | + const [password, setPassword] = useState(""); | |
| 1359 | + const [email, setEmail] = useState(""); | |
| 1360 | + const [phone, setPhone] = useState(""); | |
| 1361 | + const [roleId, setRoleId] = useState(""); | |
| 1362 | + const [state, setState] = useState(true); | |
| 1363 | + const [selectedLocationIds, setSelectedLocationIds] = useState<Set<string>>(new Set()); | |
| 1364 | + | |
| 1365 | + const [roleOptions, setRoleOptions] = useState<RoleDto[]>([]); | |
| 1366 | + const [loadingRoles, setLoadingRoles] = useState(false); | |
| 1367 | + | |
| 1368 | + const [locationOptions, setLocationOptions] = useState<LocationDto[]>([]); | |
| 1369 | + const [loadingLocations, setLoadingLocations] = useState(false); | |
| 1370 | + const [locationKeyword, setLocationKeyword] = useState(""); | |
| 1371 | + | |
| 1372 | + const abortRef = useRef<AbortController | null>(null); | |
| 1373 | + | |
| 1374 | + const resetForm = () => { | |
| 1375 | + setFullName(""); | |
| 1376 | + setUserName(""); | |
| 1377 | + setPassword(""); | |
| 1378 | + setEmail(""); | |
| 1379 | + setPhone(""); | |
| 1380 | + setRoleId(""); | |
| 1381 | + setState(true); | |
| 1382 | + setSelectedLocationIds(new Set()); | |
| 1383 | + }; | |
| 1384 | + | |
| 1385 | + const loadAllRolesForSelect = async (signal: AbortSignal) => { | |
| 1386 | + const out: RoleDto[] = []; | |
| 1387 | + let page = 1; | |
| 1388 | + const size = 100; | |
| 1389 | + for (;;) { | |
| 1390 | + const res = await getRoles({ skipCount: page, maxResultCount: size }, signal); | |
| 1391 | + out.push(...(res.items ?? [])); | |
| 1392 | + if (!res.items || res.items.length < size) break; | |
| 1393 | + page += 1; | |
| 1394 | + if (page > 200) break; // safety bound | |
| 1395 | + } | |
| 1396 | + // 去重(防止后端实现差异) | |
| 1397 | + const m = new Map<string, RoleDto>(); | |
| 1398 | + for (const r of out) if (r.id && !m.has(r.id)) m.set(r.id, r); | |
| 1399 | + return Array.from(m.values()); | |
| 1400 | + }; | |
| 1401 | + | |
| 1402 | + const loadAllLocationsForSelect = async (signal: AbortSignal) => { | |
| 1403 | + const out: LocationDto[] = []; | |
| 1404 | + let page = 1; | |
| 1405 | + const size = 200; | |
| 1406 | + for (;;) { | |
| 1407 | + const res = await getLocations({ skipCount: page, maxResultCount: size }, signal); | |
| 1408 | + out.push(...(res.items ?? [])); | |
| 1409 | + if (!res.items || res.items.length < size) break; | |
| 1410 | + page += 1; | |
| 1411 | + if (page > 200) break; | |
| 1412 | + } | |
| 1413 | + const m = new Map<string, LocationDto>(); | |
| 1414 | + for (const l of out) if (l.id && !m.has(l.id)) m.set(l.id, l); | |
| 1415 | + return Array.from(m.values()); | |
| 1416 | + }; | |
| 1417 | + | |
| 1418 | + useEffect(() => { | |
| 1419 | + if (!open) return; | |
| 1420 | + abortRef.current?.abort(); | |
| 1421 | + const ac = new AbortController(); | |
| 1422 | + abortRef.current = ac; | |
| 1423 | + | |
| 1424 | + setSubmitting(false); | |
| 1425 | + resetForm(); | |
| 1426 | + setLoadingRoles(true); | |
| 1427 | + setLoadingLocations(true); | |
| 1428 | + | |
| 1429 | + const run = async () => { | |
| 1430 | + try { | |
| 1431 | + const [rolesRes, locationsRes] = await Promise.all([ | |
| 1432 | + loadAllRolesForSelect(ac.signal), | |
| 1433 | + loadAllLocationsForSelect(ac.signal), | |
| 1434 | + ]); | |
| 1435 | + setRoleOptions(rolesRes); | |
| 1436 | + setLocationOptions(locationsRes); | |
| 1437 | + | |
| 1438 | + if (member?.id) { | |
| 1439 | + const detail = await getTeamMemberById(member.id, ac.signal); | |
| 1440 | + setFullName(detail.fullName ?? ""); | |
| 1441 | + setUserName(detail.userName ?? ""); | |
| 1442 | + setEmail(detail.email ?? ""); | |
| 1443 | + setPhone(detail.phone != null ? String(detail.phone) : ""); | |
| 1444 | + // 有些后端只返回 roleName 而不返回 roleId;这里做兜底匹配,避免编辑后 Save 变成 disabled | |
| 1445 | + let nextRoleId = (detail.roleId ?? "").toString().trim(); | |
| 1446 | + if (!nextRoleId && detail.roleName) { | |
| 1447 | + const roleName = String(detail.roleName).trim().toLowerCase(); | |
| 1448 | + const matched = rolesRes.find((r) => { | |
| 1449 | + const rn = String(r.roleName ?? "").trim().toLowerCase(); | |
| 1450 | + const rc = String(r.roleCode ?? "").trim().toLowerCase(); | |
| 1451 | + const rid = String(r.id ?? "").trim().toLowerCase(); | |
| 1452 | + return rn === roleName || rc === roleName || rid === roleName; | |
| 1453 | + }); | |
| 1454 | + if (matched?.id) nextRoleId = matched.id; | |
| 1455 | + } | |
| 1456 | + setRoleId(nextRoleId); | |
| 1457 | + setState(!!detail.state); | |
| 1458 | + | |
| 1459 | + const ids = detail.locationIds && detail.locationIds.length ? detail.locationIds : []; | |
| 1460 | + if (ids.length) { | |
| 1461 | + setSelectedLocationIds(new Set(ids)); | |
| 1462 | + } else if (detail.locations?.length) { | |
| 1463 | + // 如果后端只返回 locations(名字),则尽力映射回 id | |
| 1464 | + const labels = new Set(detail.locations); | |
| 1465 | + const inferred = new Set<string>(); | |
| 1466 | + for (const l of locationsRes) { | |
| 1467 | + const label1 = `${(l.locationCode ?? "").trim()} - ${(l.locationName ?? "").trim()}`.trim(); | |
| 1468 | + const label2 = (l.locationName ?? "").trim(); | |
| 1469 | + const label3 = (l.locationCode ?? "").trim(); | |
| 1470 | + if (labels.has(label1) || labels.has(label2) || labels.has(label3)) inferred.add(l.id); | |
| 1471 | + } | |
| 1472 | + setSelectedLocationIds(inferred); | |
| 1473 | + } | |
| 1474 | + } | |
| 1475 | + } catch (e: any) { | |
| 1476 | + if (e?.name !== "AbortError") { | |
| 1477 | + toast.error("Failed to load user form.", { | |
| 1478 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 1479 | + }); | |
| 1480 | + } | |
| 1481 | + } finally { | |
| 1482 | + setLoadingRoles(false); | |
| 1483 | + setLoadingLocations(false); | |
| 1484 | + } | |
| 1485 | + }; | |
| 1486 | + | |
| 1487 | + run(); | |
| 1488 | + return () => ac.abort(); | |
| 1489 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 1490 | + }, [open, member?.id]); | |
| 1491 | + | |
| 1492 | + // 与新增一致:只判断必填项是否有值,有值即可点保存;无值则提交时弹提示 | |
| 1493 | + const canSubmit = useMemo(() => { | |
| 1494 | + if (!fullName.trim()) return false; | |
| 1495 | + if (!userName.trim()) return false; | |
| 1496 | + if (!roleId.trim()) return false; | |
| 1497 | + if (selectedLocationIds.size === 0) return false; | |
| 1498 | + if (!isEdit && !password.trim()) return false; | |
| 1499 | + return true; | |
| 1500 | + }, [fullName, userName, roleId, selectedLocationIds, isEdit, password]); | |
| 1501 | + | |
| 1502 | + const toggleLocation = (id: string, checked: boolean) => { | |
| 1503 | + setSelectedLocationIds((prev) => { | |
| 1504 | + const next = new Set(prev); | |
| 1505 | + if (checked) next.add(id); | |
| 1506 | + else next.delete(id); | |
| 1507 | + return next; | |
| 1508 | + }); | |
| 1509 | + }; | |
| 1510 | + | |
| 1511 | + const submit = async (e?: React.MouseEvent) => { | |
| 1512 | + e?.preventDefault(); | |
| 1513 | + e?.stopPropagation(); | |
| 1514 | + | |
| 1515 | + console.log("[MemberDialog] submit called", { isEdit, memberId: member?.id, canSubmit, roleId, fullName, userName, selectedLocationIds: selectedLocationIds.size }); | |
| 1516 | + | |
| 1517 | + // 先校验必填项 | |
| 1518 | + if (!canSubmit) { | |
| 1519 | + const missing: string[] = []; | |
| 1520 | + if (!fullName.trim()) missing.push("Full Name"); | |
| 1521 | + if (!userName.trim()) missing.push("User Name"); | |
| 1522 | + if (!roleId.trim()) missing.push("Role"); | |
| 1523 | + if (selectedLocationIds.size === 0) missing.push("Locations"); | |
| 1524 | + if (!isEdit && !password.trim()) missing.push("Password"); | |
| 1525 | + toast.error("Missing required fields.", { | |
| 1526 | + description: `Please fill: ${missing.join("、")}.`, | |
| 1527 | + }); | |
| 1528 | + return; | |
| 1529 | + } | |
| 1530 | + | |
| 1531 | + if (!isEdit && !member?.id) { | |
| 1532 | + // 新增模式 | |
| 1533 | + setSubmitting(true); | |
| 1534 | + try { | |
| 1535 | + const locationIds = Array.from(selectedLocationIds); | |
| 1536 | + console.log("[MemberDialog] Creating user", { fullName, userName, roleId, locationIds }); | |
| 1537 | + await createTeamMember({ | |
| 1538 | + fullName: fullName.trim(), | |
| 1539 | + userName: userName.trim(), | |
| 1540 | + password: password.trim(), | |
| 1541 | + email: email.trim() ? email.trim() : null, | |
| 1542 | + phone: phone != null && String(phone).trim() ? String(phone).trim() : null, | |
| 1543 | + roleId: roleId.trim(), | |
| 1544 | + locationIds, | |
| 1545 | + state, | |
| 1546 | + }); | |
| 1547 | + toast.success("User created.", { description: "A new user has been created successfully." }); | |
| 1548 | + onOpenChange(false); | |
| 1549 | + onSaved(); | |
| 1550 | + } catch (e: any) { | |
| 1551 | + console.error("[MemberDialog] Create error", e); | |
| 1552 | + toast.error("Failed to create user.", { | |
| 1553 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 1554 | + }); | |
| 1555 | + } finally { | |
| 1556 | + setSubmitting(false); | |
| 1557 | + } | |
| 1558 | + } else if (isEdit && member?.id) { | |
| 1559 | + // 编辑模式 | |
| 1560 | + setSubmitting(true); | |
| 1561 | + try { | |
| 1562 | + const locationIds = Array.from(selectedLocationIds); | |
| 1563 | + console.log("[MemberDialog] Updating user", { id: member.id, fullName, userName, roleId, locationIds }); | |
| 1564 | + await updateTeamMember(member.id, { | |
| 1565 | + fullName: fullName.trim(), | |
| 1566 | + userName: userName.trim(), | |
| 1567 | + password: password.trim() ? password.trim() : null, | |
| 1568 | + email: email.trim() ? email.trim() : null, | |
| 1569 | + phone: phone != null && String(phone).trim() ? String(phone).trim() : null, | |
| 1570 | + roleId: roleId.trim(), | |
| 1571 | + locationIds, | |
| 1572 | + state, | |
| 1573 | + }); | |
| 1574 | + toast.success("User updated.", { description: "Changes have been saved successfully." }); | |
| 1575 | + onOpenChange(false); | |
| 1576 | + onSaved(); | |
| 1577 | + } catch (e: any) { | |
| 1578 | + console.error("[MemberDialog] Update error", e); | |
| 1579 | + toast.error("Failed to update user.", { | |
| 1580 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 1581 | + }); | |
| 1582 | + } finally { | |
| 1583 | + setSubmitting(false); | |
| 1584 | + } | |
| 1585 | + } else { | |
| 1586 | + console.error("[MemberDialog] Invalid state", { isEdit, memberId: member?.id }); | |
| 1587 | + toast.error("Invalid form state.", { | |
| 1588 | + description: "Please refresh and try again.", | |
| 1589 | + }); | |
| 1590 | + } | |
| 1591 | + }; | |
| 1592 | + | |
| 1593 | + const locationLabel = (l: LocationDto) => { | |
| 1594 | + const code = (l.locationCode ?? "").trim(); | |
| 1595 | + const name = (l.locationName ?? "").trim(); | |
| 1596 | + return code && name ? `${code} - ${name}` : name || code || l.id; | |
| 1597 | + }; | |
| 1598 | + | |
| 1599 | + const filteredLocations = useMemo(() => { | |
| 1600 | + const kw = locationKeyword.trim().toLowerCase(); | |
| 1601 | + if (!kw) return locationOptions; | |
| 1602 | + return locationOptions.filter((l) => locationLabel(l).toLowerCase().includes(kw)); | |
| 1603 | + }, [locationOptions, locationKeyword]); | |
| 1604 | + | |
| 1075 | 1605 | return ( |
| 1076 | 1606 | <Dialog open={open} onOpenChange={onOpenChange}> |
| 1077 | - <DialogContent className="sm:max-w-[500px]"> | |
| 1607 | + <DialogContent className="sm:max-w-none" style={{ width: "50%" }}> | |
| 1078 | 1608 | <DialogHeader> |
| 1079 | - <DialogTitle>Add Team Member / Manager</DialogTitle> | |
| 1080 | - <DialogDescription>Create a user account and assign them to locations.</DialogDescription> | |
| 1609 | + <DialogTitle>{isEdit ? "Edit User" : "New User"}</DialogTitle> | |
| 1610 | + <DialogDescription>Role is single-select; Locations is multi-select.</DialogDescription> | |
| 1081 | 1611 | </DialogHeader> |
| 1082 | - <div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto pr-1"> | |
| 1612 | + | |
| 1613 | + <div className="space-y-4 py-4 max-h-[70vh] overflow-y-auto pr-1"> | |
| 1083 | 1614 | <div className="grid grid-cols-2 gap-4"> |
| 1084 | 1615 | <div className="space-y-2"> |
| 1085 | - <Label>Full Name</Label> | |
| 1086 | - <Input placeholder="John Doe" /> | |
| 1616 | + <Label>Full Name *</Label> | |
| 1617 | + <Input value={fullName} onChange={(e) => setFullName(e.target.value)} placeholder="John Doe" /> | |
| 1087 | 1618 | </div> |
| 1088 | - <div className="space-y-2"> | |
| 1089 | - <Label>Role</Label> | |
| 1090 | - <Select> | |
| 1091 | - <SelectTrigger><SelectValue placeholder="Select Role" /></SelectTrigger> | |
| 1092 | - <SelectContent> | |
| 1093 | - {roles.map(r => <SelectItem key={r.id} value={r.name}>{r.name}</SelectItem>)} | |
| 1094 | - </SelectContent> | |
| 1095 | - </Select> | |
| 1619 | + <div className="space-y-2"> | |
| 1620 | + <Label>User Name *</Label> | |
| 1621 | + <Input value={userName} onChange={(e) => setUserName(e.target.value)} placeholder="username" /> | |
| 1096 | 1622 | </div> |
| 1097 | 1623 | </div> |
| 1098 | 1624 | |
| 1099 | - <div className="space-y-2"> | |
| 1100 | - <Label>Password</Label> | |
| 1101 | - <Input type="password" placeholder="Enter password" autoComplete="new-password" className="w-full" /> | |
| 1102 | - </div> | |
| 1103 | - | |
| 1104 | - <div className="space-y-2"> | |
| 1105 | - <Label>Email Address</Label> | |
| 1106 | - <Input type="email" placeholder="john@example.com" /> | |
| 1625 | + {!isEdit && ( | |
| 1626 | + <div className="space-y-2"> | |
| 1627 | + <Label>Password *</Label> | |
| 1628 | + <Input | |
| 1629 | + type="password" | |
| 1630 | + value={password} | |
| 1631 | + onChange={(e) => setPassword(e.target.value)} | |
| 1632 | + placeholder="Enter password" | |
| 1633 | + autoComplete="new-password" | |
| 1634 | + className="w-full" | |
| 1635 | + /> | |
| 1636 | + </div> | |
| 1637 | + )} | |
| 1638 | + | |
| 1639 | + {isEdit && ( | |
| 1640 | + <div className="space-y-2"> | |
| 1641 | + <Label>Password (Optional)</Label> | |
| 1642 | + <Input | |
| 1643 | + type="password" | |
| 1644 | + value={password} | |
| 1645 | + onChange={(e) => setPassword(e.target.value)} | |
| 1646 | + placeholder="Enter new password (optional)" | |
| 1647 | + autoComplete="new-password" | |
| 1648 | + className="w-full" | |
| 1649 | + /> | |
| 1650 | + </div> | |
| 1651 | + )} | |
| 1652 | + | |
| 1653 | + <div className="grid grid-cols-2 gap-4"> | |
| 1654 | + <div className="space-y-2"> | |
| 1655 | + <Label>Email</Label> | |
| 1656 | + <Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="john@example.com" /> | |
| 1657 | + </div> | |
| 1658 | + <div className="space-y-2"> | |
| 1659 | + <Label>Phone</Label> | |
| 1660 | + <Input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+1 (555) 000-0000" /> | |
| 1661 | + </div> | |
| 1107 | 1662 | </div> |
| 1108 | 1663 | |
| 1109 | 1664 | <div className="space-y-2"> |
| 1110 | - <Label>Phone Number</Label> | |
| 1111 | - <Input type="tel" placeholder="+1 (555) 000-0000" /> | |
| 1665 | + <Label>Role *</Label> | |
| 1666 | + <Select | |
| 1667 | + value={roleId ? roleId : ""} | |
| 1668 | + onValueChange={(v) => { | |
| 1669 | + const newRoleId = (v && v.trim()) ? v.trim() : ""; | |
| 1670 | + console.log("[MemberDialog] Role changed", { old: roleId, new: newRoleId, v }); | |
| 1671 | + setRoleId(newRoleId); | |
| 1672 | + }} | |
| 1673 | + disabled={loadingRoles} | |
| 1674 | + > | |
| 1675 | + <SelectTrigger className="h-10 rounded-md border border-gray-200 bg-white"> | |
| 1676 | + <SelectValue placeholder={loadingRoles ? "Loading roles..." : "Select role"} /> | |
| 1677 | + </SelectTrigger> | |
| 1678 | + <SelectContent> | |
| 1679 | + {roleOptions.map((r) => ( | |
| 1680 | + <SelectItem key={r.id} value={r.id}> | |
| 1681 | + {r.roleName ?? r.roleCode ?? r.id} | |
| 1682 | + </SelectItem> | |
| 1683 | + ))} | |
| 1684 | + </SelectContent> | |
| 1685 | + </Select> | |
| 1112 | 1686 | </div> |
| 1113 | 1687 | |
| 1114 | 1688 | <div className="space-y-2"> |
| 1115 | - <ScrollArea className="h-[120px] w-full border rounded-md p-2"> | |
| 1689 | + <Label>Locations *</Label> | |
| 1690 | + <div className="flex items-center justify-between gap-2"> | |
| 1691 | + <Input | |
| 1692 | + value={locationKeyword} | |
| 1693 | + onChange={(e) => setLocationKeyword(e.target.value)} | |
| 1694 | + placeholder="Search locations" | |
| 1695 | + className="h-9" | |
| 1696 | + /> | |
| 1697 | + <div className="text-xs text-gray-500 shrink-0">{selectedLocationIds.size} selected</div> | |
| 1698 | + </div> | |
| 1699 | + <ScrollArea className="h-[180px] w-full border rounded-md p-2"> | |
| 1116 | 1700 | <div className="space-y-2"> |
| 1117 | - {MOCK_LOCATIONS.map(loc => ( | |
| 1118 | - <div key={loc.id} className="flex items-center space-x-2"> | |
| 1119 | - <Checkbox id={`loc-${loc.id}`} /> | |
| 1120 | - <label htmlFor={`loc-${loc.id}`} className="text-sm cursor-pointer w-full hover:bg-gray-50 p-1 rounded"> | |
| 1121 | - {loc.name} | |
| 1122 | - </label> | |
| 1123 | - </div> | |
| 1124 | - ))} | |
| 1701 | + {loadingLocations ? ( | |
| 1702 | + <div className="text-sm text-gray-500 py-2">Loading...</div> | |
| 1703 | + ) : ( | |
| 1704 | + filteredLocations.map((l) => ( | |
| 1705 | + <div key={l.id} className="flex items-center space-x-2"> | |
| 1706 | + <Checkbox | |
| 1707 | + id={`loc-${l.id}`} | |
| 1708 | + checked={selectedLocationIds.has(l.id)} | |
| 1709 | + onCheckedChange={(v) => toggleLocation(l.id, !!v)} | |
| 1710 | + /> | |
| 1711 | + <label htmlFor={`loc-${l.id}`} className="text-sm cursor-pointer w-full hover:bg-gray-50 p-1 rounded"> | |
| 1712 | + {locationLabel(l)} | |
| 1713 | + </label> | |
| 1714 | + </div> | |
| 1715 | + )) | |
| 1716 | + )} | |
| 1125 | 1717 | </div> |
| 1126 | 1718 | </ScrollArea> |
| 1127 | 1719 | <p className="text-xs text-gray-500">* Users must be assigned to at least one location.</p> |
| 1128 | 1720 | </div> |
| 1129 | 1721 | |
| 1130 | - <div className="flex items-center gap-2"> | |
| 1131 | - <Switch id="member-status" defaultChecked /> | |
| 1132 | - <Label htmlFor="member-status">Active Account</Label> | |
| 1722 | + <div className="flex items-center gap-2 pt-2"> | |
| 1723 | + <Switch id="member-status" checked={state} onCheckedChange={setState} /> | |
| 1724 | + <Label htmlFor="member-status">{state ? "Active" : "Inactive"}</Label> | |
| 1133 | 1725 | </div> |
| 1134 | 1726 | </div> |
| 1727 | + | |
| 1135 | 1728 | <DialogFooter> |
| 1136 | - <Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button> | |
| 1137 | - <Button onClick={() => onOpenChange(false)} className="bg-blue-600 text-white hover:bg-blue-700">Create User</Button> | |
| 1729 | + <Button variant="outline" onClick={() => onOpenChange(false)}> | |
| 1730 | + Cancel | |
| 1731 | + </Button> | |
| 1732 | + <Button | |
| 1733 | + disabled={submitting || !canSubmit} | |
| 1734 | + onClick={(e) => { | |
| 1735 | + e.preventDefault(); | |
| 1736 | + e.stopPropagation(); | |
| 1737 | + submit(e); | |
| 1738 | + }} | |
| 1739 | + className="bg-blue-600 text-white hover:bg-blue-700" | |
| 1740 | + > | |
| 1741 | + {submitting ? "Saving..." : isEdit ? "Save" : "Create"} | |
| 1742 | + </Button> | |
| 1743 | + </DialogFooter> | |
| 1744 | + </DialogContent> | |
| 1745 | + </Dialog> | |
| 1746 | + ); | |
| 1747 | +} | |
| 1748 | + | |
| 1749 | +function DeleteMemberDialog({ | |
| 1750 | + open, | |
| 1751 | + member, | |
| 1752 | + onOpenChange, | |
| 1753 | + onDeleted, | |
| 1754 | +}: { | |
| 1755 | + open: boolean; | |
| 1756 | + member: TeamMemberDto | null; | |
| 1757 | + onOpenChange: (open: boolean) => void; | |
| 1758 | + onDeleted: () => void; | |
| 1759 | +}) { | |
| 1760 | + const [submitting, setSubmitting] = useState(false); | |
| 1761 | + | |
| 1762 | + const name = useMemo(() => { | |
| 1763 | + const n = (member?.fullName ?? "").trim(); | |
| 1764 | + const code = (member?.userName ?? "").trim(); | |
| 1765 | + return n || code || "this user"; | |
| 1766 | + }, [member?.fullName, member?.userName]); | |
| 1767 | + | |
| 1768 | + const submit = async () => { | |
| 1769 | + console.log("submit", member); | |
| 1770 | + if (!member?.id) return; | |
| 1771 | + setSubmitting(true); | |
| 1772 | + try { | |
| 1773 | + await deleteTeamMember(member.id); | |
| 1774 | + toast.success("User deleted.", { description: "The user has been removed successfully." }); | |
| 1775 | + onOpenChange(false); | |
| 1776 | + onDeleted(); | |
| 1777 | + } catch (e: any) { | |
| 1778 | + toast.error("Failed to delete user.", { | |
| 1779 | + description: e?.message ? String(e.message) : "Please try again.", | |
| 1780 | + }); | |
| 1781 | + } finally { | |
| 1782 | + setSubmitting(false); | |
| 1783 | + } | |
| 1784 | + }; | |
| 1785 | + | |
| 1786 | + return ( | |
| 1787 | + <Dialog open={open} onOpenChange={onOpenChange}> | |
| 1788 | + <DialogContent className="sm:max-w-none" style={{ width: "30%" }}> | |
| 1789 | + <DialogHeader> | |
| 1790 | + <DialogTitle>Delete User</DialogTitle> | |
| 1791 | + <DialogDescription>This action cannot be undone.</DialogDescription> | |
| 1792 | + </DialogHeader> | |
| 1793 | + | |
| 1794 | + <div className="text-sm text-gray-700"> | |
| 1795 | + Are you sure you want to delete <span className="font-medium">{name}</span>? | |
| 1796 | + </div> | |
| 1797 | + | |
| 1798 | + <DialogFooter className="flex-row flex-wrap justify-end"> | |
| 1799 | + <Button variant="outline" className="min-w-24" onClick={() => onOpenChange(false)}> | |
| 1800 | + Cancel | |
| 1801 | + </Button> | |
| 1802 | + <Button | |
| 1803 | + variant="destructive" | |
| 1804 | + className="min-w-24" | |
| 1805 | + disabled={submitting} | |
| 1806 | + onClick={submit} | |
| 1807 | + > | |
| 1808 | + {submitting ? "Deleting..." : "Delete"} | |
| 1809 | + </Button> | |
| 1138 | 1810 | </DialogFooter> |
| 1139 | 1811 | </DialogContent> |
| 1140 | 1812 | </Dialog> | ... | ... |
美国版/Food Labeling Management Platform/src/components/ui/dialog.tsx
| ... | ... | @@ -30,12 +30,13 @@ function DialogClose({ |
| 30 | 30 | return <DialogPrimitive.Close data-slot="dialog-close" {...props} />; |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | -function DialogOverlay({ | |
| 34 | - className, | |
| 35 | - ...props | |
| 36 | -}: React.ComponentProps<typeof DialogPrimitive.Overlay>) { | |
| 33 | +const DialogOverlay = React.forwardRef< | |
| 34 | + React.ElementRef<typeof DialogPrimitive.Overlay>, | |
| 35 | + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> | |
| 36 | +>(({ className, ...props }, ref) => { | |
| 37 | 37 | return ( |
| 38 | 38 | <DialogPrimitive.Overlay |
| 39 | + ref={ref} | |
| 39 | 40 | data-slot="dialog-overlay" |
| 40 | 41 | className={cn( |
| 41 | 42 | "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", |
| ... | ... | @@ -44,17 +45,18 @@ function DialogOverlay({ |
| 44 | 45 | {...props} |
| 45 | 46 | /> |
| 46 | 47 | ); |
| 47 | -} | |
| 48 | +}); | |
| 49 | +DialogOverlay.displayName = "DialogOverlay"; | |
| 48 | 50 | |
| 49 | -function DialogContent({ | |
| 50 | - className, | |
| 51 | - children, | |
| 52 | - ...props | |
| 53 | -}: React.ComponentProps<typeof DialogPrimitive.Content>) { | |
| 51 | +const DialogContent = React.forwardRef< | |
| 52 | + React.ElementRef<typeof DialogPrimitive.Content>, | |
| 53 | + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> | |
| 54 | +>(({ className, children, ...props }, ref) => { | |
| 54 | 55 | return ( |
| 55 | 56 | <DialogPortal data-slot="dialog-portal"> |
| 56 | 57 | <DialogOverlay /> |
| 57 | 58 | <DialogPrimitive.Content |
| 59 | + ref={ref} | |
| 58 | 60 | data-slot="dialog-content" |
| 59 | 61 | className={cn( |
| 60 | 62 | "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", |
| ... | ... | @@ -70,7 +72,8 @@ function DialogContent({ |
| 70 | 72 | </DialogPrimitive.Content> |
| 71 | 73 | </DialogPortal> |
| 72 | 74 | ); |
| 73 | -} | |
| 75 | +}); | |
| 76 | +DialogContent.displayName = "DialogContent"; | |
| 74 | 77 | |
| 75 | 78 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { |
| 76 | 79 | return ( | ... | ... |
美国版/Food Labeling Management Platform/src/services/rbacRoleService.ts
| ... | ... | @@ -38,6 +38,41 @@ export async function updateRbacRole(id: string, input: RbacRoleUpsertInput): Pr |
| 38 | 38 | }); |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | +function extractMenuIdsFromRoleDetail(raw: unknown): string[] { | |
| 42 | + if (!raw || typeof raw !== "object") return []; | |
| 43 | + const r = raw as Record<string, unknown>; | |
| 44 | + | |
| 45 | + // 常见命名:menuIds / MenuIds | |
| 46 | + const direct = | |
| 47 | + Array.isArray(r.menuIds) ? r.menuIds : | |
| 48 | + Array.isArray((r as any).MenuIds) ? (r as any).MenuIds : | |
| 49 | + undefined; | |
| 50 | + if (direct) return (direct as unknown[]).map(String); | |
| 51 | + | |
| 52 | + // 包一层:{ data: { menuIds } } | |
| 53 | + const data = (r as any).data ?? (r as any).Data; | |
| 54 | + if (data && typeof data === "object") { | |
| 55 | + const dd = data as Record<string, unknown>; | |
| 56 | + const fromData = Array.isArray(dd.menuIds) ? dd.menuIds : Array.isArray(dd.MenuIds) ? dd.MenuIds : undefined; | |
| 57 | + if (fromData) return (fromData as unknown[]).map(String); | |
| 58 | + } | |
| 59 | + | |
| 60 | + // 可能是 roleMenus: [{ menuId: '...' }] 之类 | |
| 61 | + const roleMenus = Array.isArray((r as any).roleMenus) ? (r as any).roleMenus : Array.isArray((r as any).RoleMenus) ? (r as any).RoleMenus : undefined; | |
| 62 | + if (Array.isArray(roleMenus)) { | |
| 63 | + const ids: string[] = []; | |
| 64 | + for (const x of roleMenus) { | |
| 65 | + if (!x || typeof x !== "object") continue; | |
| 66 | + const rr = x as Record<string, unknown>; | |
| 67 | + const mid = rr.menuId ?? rr.MenuId ?? rr.id ?? rr.Id; | |
| 68 | + if (mid) ids.push(String(mid)); | |
| 69 | + } | |
| 70 | + return ids; | |
| 71 | + } | |
| 72 | + | |
| 73 | + return []; | |
| 74 | +} | |
| 75 | + | |
| 41 | 76 | /** |
| 42 | 77 | * Swagger(常见约定):DELETE /api/app/rbac-role,Body 为 ID 数组 |
| 43 | 78 | */ |
| ... | ... | @@ -54,3 +89,16 @@ export async function deleteRbacRole(id: string): Promise<void> { |
| 54 | 89 | await deleteRbacRoles([id]); |
| 55 | 90 | } |
| 56 | 91 | |
| 92 | +/** | |
| 93 | + * Swagger: GET /api/app/rbac-role/{id} | |
| 94 | + * 用于“查看当前角色菜单权限”,尽量从返回对象里提取 menuIds 字段。 | |
| 95 | + */ | |
| 96 | +export async function getRbacRoleMenuIds(roleId: string, signal?: AbortSignal): Promise<string[]> { | |
| 97 | + const raw = await api.requestJson<unknown>({ | |
| 98 | + path: `${PATH}/${encodeURIComponent(roleId)}`, | |
| 99 | + method: "GET", | |
| 100 | + signal, | |
| 101 | + }); | |
| 102 | + return extractMenuIdsFromRoleDetail(raw); | |
| 103 | +} | |
| 104 | + | ... | ... |
美国版/Food Labeling Management Platform/src/services/teamMemberService.ts
0 → 100644
| 1 | +import { createApiClient } from "../lib/apiClient"; | |
| 2 | +import type { | |
| 3 | + PagedResultDto, | |
| 4 | + TeamMemberCreateInput, | |
| 5 | + TeamMemberDto, | |
| 6 | + TeamMemberGetListInput, | |
| 7 | + TeamMemberUpdateInput, | |
| 8 | +} from "../types/teamMember"; | |
| 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 = "/team-member"; | |
| 21 | + | |
| 22 | +function toStringArray(v: unknown): string[] { | |
| 23 | + if (Array.isArray(v)) return v.map((x) => String(x)); | |
| 24 | + return []; | |
| 25 | +} | |
| 26 | + | |
| 27 | +function toIdArray(v: unknown): string[] { | |
| 28 | + if (!Array.isArray(v)) return []; | |
| 29 | + const out: string[] = []; | |
| 30 | + for (const x of v) { | |
| 31 | + if (x === null || x === undefined) continue; | |
| 32 | + if (typeof x === "string" || typeof x === "number") { | |
| 33 | + out.push(String(x)); | |
| 34 | + continue; | |
| 35 | + } | |
| 36 | + if (typeof x === "object") { | |
| 37 | + const o = x as Record<string, unknown>; | |
| 38 | + const id = o.id ?? o.Id ?? o.locationId ?? o.LocationId ?? o.location_id ?? o.locationID; | |
| 39 | + if (id !== null && id !== undefined) out.push(String(id)); | |
| 40 | + } | |
| 41 | + } | |
| 42 | + return out; | |
| 43 | +} | |
| 44 | + | |
| 45 | +function toLocationLabels(v: unknown): string[] { | |
| 46 | + if (!Array.isArray(v)) return []; | |
| 47 | + const out: string[] = []; | |
| 48 | + for (const x of v) { | |
| 49 | + if (x === null || x === undefined) continue; | |
| 50 | + if (typeof x === "string" || typeof x === "number") { | |
| 51 | + out.push(String(x)); | |
| 52 | + continue; | |
| 53 | + } | |
| 54 | + if (typeof x === "object") { | |
| 55 | + const o = x as Record<string, unknown>; | |
| 56 | + const code = | |
| 57 | + (o.locationCode ?? o.LocationCode ?? o.code ?? o.Code ?? o.location_code ?? o.locationCodeId) as unknown; | |
| 58 | + const name = | |
| 59 | + (o.locationName ?? o.LocationName ?? o.name ?? o.Name ?? o.location_name) as unknown; | |
| 60 | + const id = (o.id ?? o.Id ?? o.locationId ?? o.LocationId) as unknown; | |
| 61 | + | |
| 62 | + const codeS = code === null || code === undefined ? "" : String(code).trim(); | |
| 63 | + const nameS = name === null || name === undefined ? "" : String(name).trim(); | |
| 64 | + const idS = id === null || id === undefined ? "" : String(id).trim(); | |
| 65 | + | |
| 66 | + if (codeS && nameS) out.push(`${codeS} - ${nameS}`); | |
| 67 | + else if (nameS) out.push(nameS); | |
| 68 | + else if (codeS) out.push(codeS); | |
| 69 | + else if (idS) out.push(idS); | |
| 70 | + } | |
| 71 | + } | |
| 72 | + return out; | |
| 73 | +} | |
| 74 | + | |
| 75 | +function normalizeTeamMemberDto(row: unknown): TeamMemberDto { | |
| 76 | + if (!row || typeof row !== "object") return { id: "" }; | |
| 77 | + const r = row as Record<string, unknown>; | |
| 78 | + | |
| 79 | + // ABP 常见 camelCase + 少量 PascalCase 兼容 | |
| 80 | + const id = String( | |
| 81 | + r.id ?? r.Id ?? r.userId ?? r.UserId ?? r.user_id ?? r.UserID ?? r.memberId ?? r.MemberId ?? "", | |
| 82 | + ); | |
| 83 | + | |
| 84 | + // name fields: fullName or name | |
| 85 | + const fullName = (r.fullName ?? r.FullName ?? r.name ?? r.Name) as string | null | undefined; | |
| 86 | + const userName = (r.userName ?? r.UserName ?? r.username ?? r.UserName) as string | null | undefined; | |
| 87 | + const email = (r.email ?? r.Email) as string | null | undefined; | |
| 88 | + const phone = (r.phone ?? r.Phone) as string | null | undefined; | |
| 89 | + | |
| 90 | + // role 可能是扁平字段(roleId/roleName),也可能是嵌套对象(role: { id, roleName }) | |
| 91 | + let roleId = (r.roleId ?? r.RoleId) as string | null | undefined; | |
| 92 | + let roleName = (r.roleName ?? r.RoleName ?? r.roleName ?? r.Role) as string | null | undefined; | |
| 93 | + const roleObj = (r.role ?? r.Role) as unknown; | |
| 94 | + if ((!roleId || !roleName) && roleObj && typeof roleObj === "object") { | |
| 95 | + const ro = roleObj as Record<string, unknown>; | |
| 96 | + roleId = (ro.id ?? ro.Id ?? ro.roleId ?? ro.RoleId ?? roleId) as string | null | undefined; | |
| 97 | + roleName = | |
| 98 | + (ro.roleName ?? ro.RoleName ?? ro.name ?? ro.Name ?? ro.role ?? ro.Role ?? roleName) as | |
| 99 | + | string | |
| 100 | + | null | |
| 101 | + | undefined; | |
| 102 | + } | |
| 103 | + | |
| 104 | + const stateRaw = r.state ?? r.State; | |
| 105 | + const state = | |
| 106 | + typeof stateRaw === "boolean" ? (stateRaw as boolean) : stateRaw === "true" ? true : stateRaw === "false" ? false : undefined; | |
| 107 | + | |
| 108 | + const rawLocationIds = | |
| 109 | + r.locationIds ?? r.LocationIds ?? r.assignedLocationIds ?? r.AssignedLocationIds ?? r.location_id_list ?? r.LocationIdList; | |
| 110 | + let locationIds = toIdArray(rawLocationIds); | |
| 111 | + | |
| 112 | + const rawLocations = | |
| 113 | + r.locations ?? r.Locations ?? r.assignedLocations ?? r.AssignedLocations ?? r.locationNames ?? r.LocationNames; | |
| 114 | + // locations 可能包含对象数组:{ id, locationCode, locationName } 或类似 | |
| 115 | + let locations = toLocationLabels(rawLocations); | |
| 116 | + | |
| 117 | + // 如果 locations 返回的是对象数组但没有显式 locationIds,则从 locations 对象里抽 id | |
| 118 | + if (locationIds.length === 0 && Array.isArray(rawLocations)) { | |
| 119 | + const inferredIds: string[] = []; | |
| 120 | + for (const x of rawLocations) { | |
| 121 | + if (typeof x !== "object" || !x) continue; | |
| 122 | + const o = x as Record<string, unknown>; | |
| 123 | + const id = o.id ?? o.Id ?? o.locationId ?? o.LocationId; | |
| 124 | + if (id !== null && id !== undefined) inferredIds.push(String(id)); | |
| 125 | + } | |
| 126 | + if (inferredIds.length) locationIds = inferredIds; | |
| 127 | + } | |
| 128 | + | |
| 129 | + // 有些接口可能只返回一个 locations,但我们仍尽量填充 | |
| 130 | + return { | |
| 131 | + id, | |
| 132 | + fullName, | |
| 133 | + userName, | |
| 134 | + email, | |
| 135 | + phone, | |
| 136 | + roleId, | |
| 137 | + roleName, | |
| 138 | + locationIds, | |
| 139 | + locations, | |
| 140 | + state: state ?? (r.status ? (String(r.status).toLowerCase() === "active") : undefined), | |
| 141 | + }; | |
| 142 | +} | |
| 143 | + | |
| 144 | +export async function getTeamMembers( | |
| 145 | + input: TeamMemberGetListInput, | |
| 146 | + signal?: AbortSignal, | |
| 147 | +): Promise<PagedResultDto<TeamMemberDto>> { | |
| 148 | + const raw = await api.requestJson<PagedResultDto<unknown>>({ | |
| 149 | + path: PATH, | |
| 150 | + method: "GET", | |
| 151 | + query: { | |
| 152 | + SkipCount: input.skipCount, | |
| 153 | + MaxResultCount: input.maxResultCount, | |
| 154 | + Keyword: input.keyword, | |
| 155 | + }, | |
| 156 | + signal, | |
| 157 | + }); | |
| 158 | + | |
| 159 | + // requestJson 已做分页 shape 规范化,但这里做 DTO 规范 | |
| 160 | + const items = (raw.items ?? []) as unknown[]; | |
| 161 | + return { | |
| 162 | + totalCount: raw.totalCount ?? 0, | |
| 163 | + items: items.map(normalizeTeamMemberDto), | |
| 164 | + }; | |
| 165 | +} | |
| 166 | + | |
| 167 | +export async function getTeamMemberById(id: string, signal?: AbortSignal): Promise<TeamMemberDto> { | |
| 168 | + const raw = await api.requestJson<unknown>({ | |
| 169 | + path: `${PATH}/${encodeURIComponent(id)}`, | |
| 170 | + method: "GET", | |
| 171 | + signal, | |
| 172 | + }); | |
| 173 | + return normalizeTeamMemberDto(raw); | |
| 174 | +} | |
| 175 | + | |
| 176 | +function toPhoneNumber(v: string | number | null | undefined): number | null { | |
| 177 | + if (v === null || v === undefined || v === "") return null; | |
| 178 | + const s = String(v).trim(); | |
| 179 | + if (!s) return null; | |
| 180 | + const num = Number(s.replace(/\D/g, "")) || 0; | |
| 181 | + return num; | |
| 182 | +} | |
| 183 | + | |
| 184 | +function buildCreatePayload(input: TeamMemberCreateInput): Record<string, unknown> { | |
| 185 | + const phoneVal = input.phone != null && input.phone !== "" ? toPhoneNumber(String(input.phone)) : null; | |
| 186 | + return { | |
| 187 | + fullName: input.fullName, | |
| 188 | + userName: input.userName, | |
| 189 | + password: input.password, | |
| 190 | + email: input.email ?? null, | |
| 191 | + phone: phoneVal, | |
| 192 | + roleId: input.roleId, | |
| 193 | + // 兼容:后端可能叫 locationIds 或 locations | |
| 194 | + locationIds: input.locationIds, | |
| 195 | + locations: input.locationIds, | |
| 196 | + state: input.state, | |
| 197 | + }; | |
| 198 | +} | |
| 199 | + | |
| 200 | +function buildUpdatePayload(input: TeamMemberUpdateInput): Record<string, unknown> { | |
| 201 | + const phoneVal = input.phone != null && input.phone !== "" ? toPhoneNumber(String(input.phone)) : null; | |
| 202 | + const payload: Record<string, unknown> = { | |
| 203 | + fullName: input.fullName, | |
| 204 | + userName: input.userName, | |
| 205 | + email: input.email ?? null, | |
| 206 | + phone: phoneVal, | |
| 207 | + roleId: input.roleId, | |
| 208 | + locationIds: input.locationIds, | |
| 209 | + locations: input.locationIds, | |
| 210 | + state: input.state, | |
| 211 | + }; | |
| 212 | + if (input.password) payload.password = input.password; | |
| 213 | + return payload; | |
| 214 | +} | |
| 215 | + | |
| 216 | +export async function createTeamMember(input: TeamMemberCreateInput): Promise<TeamMemberDto> { | |
| 217 | + const raw = await api.requestJson<unknown>({ | |
| 218 | + path: PATH, | |
| 219 | + method: "POST", | |
| 220 | + body: buildCreatePayload(input), | |
| 221 | + }); | |
| 222 | + return normalizeTeamMemberDto(raw); | |
| 223 | +} | |
| 224 | + | |
| 225 | +export async function updateTeamMember(id: string, input: TeamMemberUpdateInput): Promise<TeamMemberDto> { | |
| 226 | + const raw = await api.requestJson<unknown>({ | |
| 227 | + path: `${PATH}/${encodeURIComponent(id)}`, | |
| 228 | + method: "PUT", | |
| 229 | + body: buildUpdatePayload(input), | |
| 230 | + }); | |
| 231 | + return normalizeTeamMemberDto(raw); | |
| 232 | +} | |
| 233 | + | |
| 234 | +export async function deleteTeamMember(id: string): Promise<void> { | |
| 235 | + await api.requestJson<unknown>({ | |
| 236 | + path: `${PATH}/${encodeURIComponent(id)}`, | |
| 237 | + method: "DELETE", | |
| 238 | + }); | |
| 239 | +} | |
| 240 | + | ... | ... |
美国版/Food Labeling Management Platform/src/types/teamMember.ts
0 → 100644
| 1 | +export type TeamMemberDto = { | |
| 2 | + id: string; | |
| 3 | + | |
| 4 | + // Basic fields | |
| 5 | + fullName?: string | null; | |
| 6 | + userName?: string | null; | |
| 7 | + email?: string | null; | |
| 8 | + phone?: string | null; | |
| 9 | + | |
| 10 | + // Role (single) | |
| 11 | + roleId?: string | null; | |
| 12 | + roleName?: string | null; | |
| 13 | + | |
| 14 | + // Locations (multi) | |
| 15 | + locationIds?: string[]; | |
| 16 | + locations?: string[]; | |
| 17 | + | |
| 18 | + state?: boolean | null; | |
| 19 | +}; | |
| 20 | + | |
| 21 | +export type PagedResultDto<T> = { | |
| 22 | + totalCount: number; | |
| 23 | + items: T[]; | |
| 24 | +}; | |
| 25 | + | |
| 26 | +export type TeamMemberGetListInput = { | |
| 27 | + skipCount: number; // pageIndex (1-based) | |
| 28 | + maxResultCount: number; // pageSize | |
| 29 | + keyword?: string; | |
| 30 | +}; | |
| 31 | + | |
| 32 | +export type TeamMemberCreateInput = { | |
| 33 | + fullName: string; | |
| 34 | + userName: string; | |
| 35 | + password: string; | |
| 36 | + email?: string | null; | |
| 37 | + phone?: string | null; | |
| 38 | + roleId: string; | |
| 39 | + locationIds: string[]; | |
| 40 | + state: boolean; | |
| 41 | +}; | |
| 42 | + | |
| 43 | +export type TeamMemberUpdateInput = { | |
| 44 | + fullName: string; | |
| 45 | + userName: string; | |
| 46 | + password?: string | null; | |
| 47 | + email?: string | null; | |
| 48 | + phone?: string | null; | |
| 49 | + roleId: string; | |
| 50 | + locationIds: string[]; | |
| 51 | + state: boolean; | |
| 52 | +}; | |
| 53 | + | ... | ... |