@opsnow-mcp/opsnow-mcp-common-ui-server 1.0.38 → 1.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1755,6 +1755,34 @@ const multiCspRateInfo = {
1755
1755
  />`,
1756
1756
  description: "수집 통화 분리 — 멀티 CSP 혼재 예제입니다. 기준 통화(USD)는 환율 앵커로 유지하되 '변환 없음(Default)' 문구는 nativeCurrency='KRW' 행에 붙습니다. USD 행에는 'N개 환율' 배지가 붙고, 히스토리 테이블 행 라벨에는 실제 환산 통화가 'Azure · Invoice 1111 (KRW)'처럼 병기됩니다 — USD로 보는 것 자체가 KRW 환율로 나누는 환산이기 때문입니다. 시리즈에도 currency 필드를 담아 주입하세요. [검색 키워드: 멀티 CSP, 수집 통화, 무환산, nativeCurrency, 행 단위 통화]"
1757
1757
  },
1758
+ {
1759
+ title: 'rate-simulation',
1760
+ code: `// ⚠️ 단독 배치는 props 참고용 — 실서비스 배치는 header-currency-switcher 예제처럼 currencySwitcherProps에 동일하게 주입
1761
+ // 환율 설정(직접 입력 · 시뮬레이션) — 2.0.11+ 동작
1762
+ // 노출 판정: showDetail && simulationAnchorCurrency 지정 && simulationAnchorCurrency !== 선택 통화
1763
+ // → USD 선택 중에도 앵커(KRW)와 다르면 노출된다 ('USD면 미노출' 내부 규칙 없음)
1764
+ const [currency, setCurrency] = useState('USD')
1765
+ const [customRate, setCustomRate] = useState(null) // 앵커 통화 1단위당 값 (null = 빈 입력)
1766
+
1767
+ <OpsnowCommonCurrencySwitcher
1768
+ value={currency}
1769
+ onChange={handleCurrencyChange}
1770
+ rateInfo={rateInfo}
1771
+ historySeries={historySeries}
1772
+ onHistoryQueryChange={handleHistoryQueryChange}
1773
+
1774
+ showDetail // 기본 false — 미사용 소비자는 변화 없음
1775
+ simulationAnchorCurrency="KRW" // 입력 라벨: 1 KRW = [입력] {선택 통화}
1776
+ customRate={customRate} // controlled — 팝오버 열 때 입력이 이 값으로 동기화
1777
+ onCustomRateChange={(rate, context) => {
1778
+ // 적용(또는 Enter) 시 입력값, 초기화·통화 변경 시 null — 입력 중에는 호출되지 않음
1779
+ // context.currency 는 3곳 모두 항상 simulationAnchorCurrency ('KRW')
1780
+ setCustomRate(rate)
1781
+ applyWhatIfRate(rate, context.currency) // 저장되지 않는 what-if 값 — 화면 환산에만 반영
1782
+ }}
1783
+ />`,
1784
+ description: "환율 직접 입력(시뮬레이션) 예제입니다 (2.0.11+). 시뮬레이션 섹션은 showDetail && simulationAnchorCurrency 지정 && 앵커 !== 선택 통화 3조건으로만 노출되며, 선택 통화가 USD여도 앵커가 다르면 노출됩니다. 입력 라벨은 '1 {simulationAnchorCurrency} = [입력] {선택 통화}'이고 입력창에 현재 환율 placeholder는 없습니다. onCustomRateChange의 context.currency는 적용·초기화·통화 변경 모두 항상 simulationAnchorCurrency이며, 값은 적용/Enter 시 입력값·초기화/통화 변경 시 null로 통지됩니다(입력 중 미호출). defaultTargetCurrency·simulationTarget은 @deprecated로 무시되니 앵커는 simulationAnchorCurrency로 지정하세요. [검색 키워드: 환율 시뮬레이션, 직접 입력, what-if, 커스텀 환율, showDetail, simulationAnchorCurrency, customRate]"
1785
+ },
1758
1786
  ];
1759
1787
  export const ToggleButtonExamples = [
1760
1788
  {
@@ -2476,4 +2476,262 @@ export const DataGridExamples = [
2476
2476
  langCd={i18n.getLocale()}
2477
2477
  />`
2478
2478
  },
2479
+ {
2480
+ title: '셀 단위 로딩 (Partial Loading — refreshCells) + 컬럼 전체 / 실패·재시도',
2481
+ description: '"이 셀만 갱신 중" 같은 부분 로딩 예제입니다. status={GRID_STATUS.LOADING}은 그리드 전체를 가리므로 부분 로딩에 쓸 수 없습니다 — status는 DATA_EXISTS로 두고, 로딩 중인 셀 키를 useRef(Set)에 담은 뒤 ref API refreshCells({ rowNodes, columns, force: true })로 해당 셀만 다시 그립니다. 값이 그대로라 force: true가 필수입니다. rowNodes를 생략하면 그 컬럼의 모든 셀이 대상(컬럼 단위)이며, 실패도 같은 패턴(별도 Set)으로 처리해 인라인 "재시도" 링크를 띄웁니다. 인디케이터로 셀 콘텐츠를 치환하지 말고 visibility: hidden으로 남기고 겹쳐야(CellProgress) autoHeight 행 높이가 유지됩니다.',
2482
+ code_props_usage: `
2483
+ import { useCallback, useMemo, useRef, useState } from 'react'
2484
+ import Box from '@mui/material/Box'
2485
+ import { useCommonComponents, useGlobalContext } from '@opsnow-common/opsnow-finops-common-ui-loader'
2486
+ import i18n from '@opsnow-common/opsnow-finops-common-i18n'
2487
+
2488
+ // 원래 콘텐츠를 hidden 으로 남겨 행 높이를 유지하고 인디케이터를 겹친다 (모듈 최상위에 둘 것)
2489
+ const CellProgress = ({ children }) => {
2490
+ const { OpsnowCommonLoading } = useCommonComponents()
2491
+ return (
2492
+ <Box sx={{ position: 'relative', display: 'inline-flex', alignItems: 'center', minWidth: '18px', minHeight: '20px' }}>
2493
+ <Box aria-hidden sx={{ visibility: 'hidden' }}>{children}</Box>
2494
+ <Box sx={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center' }}>
2495
+ <OpsnowCommonLoading size={18} />
2496
+ </Box>
2497
+ </Box>
2498
+ )
2499
+ }
2500
+
2501
+ const formatCost = (value) => '$' + Number(value).toLocaleString()
2502
+ const fakeApi = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
2503
+
2504
+ const { OpsnowCommonDataGrid, OpsnowCommonButton, OpsnowCommonLink } = useCommonComponents()
2505
+ const { CommonConst } = useGlobalContext()
2506
+ const gridRef = useRef(null)
2507
+
2508
+ const [rowData] = useState([
2509
+ { id: 'r1', account: 'opsnow-prod', service: 'Amazon EC2', cost: 12480 },
2510
+ { id: 'r2', account: 'opsnow-dev', service: 'Amazon S3', cost: 3120 },
2511
+ { id: 'r3', account: 'opsnow-stg', service: 'Amazon RDS', cost: 8640 },
2512
+ ])
2513
+ const status = CommonConst.GRID_STATUS.DATA_EXISTS
2514
+
2515
+ // state 가 아니라 ref — state 면 그리드 전체 리렌더로 스크롤이 튄다
2516
+ const loadingCellsRef = useRef(new Set())
2517
+ const errorCellsRef = useRef(new Set())
2518
+
2519
+ const refreshCostCells = useCallback((nodes) => {
2520
+ gridRef.current?.refreshCells({
2521
+ rowNodes: nodes,
2522
+ columns: ['cost'],
2523
+ force: true, // 값이 그대로라 필수
2524
+ })
2525
+ }, [])
2526
+
2527
+ const fetchCost = useCallback(async (node) => {
2528
+ const key = node.data.id + ':cost'
2529
+ loadingCellsRef.current.add(key)
2530
+ errorCellsRef.current.delete(key)
2531
+ refreshCostCells([node])
2532
+ try {
2533
+ await fakeApi(1200)
2534
+ if (node.data.id === 'r3' && !node.data.retried) { // 데모: 첫 시도만 실패
2535
+ node.data.retried = true
2536
+ throw new Error('cost fetch failed')
2537
+ }
2538
+ node.setDataValue('cost', Math.round(node.data.cost * (0.9 + Math.random() * 0.3)))
2539
+ } catch {
2540
+ errorCellsRef.current.add(key)
2541
+ } finally {
2542
+ loadingCellsRef.current.delete(key)
2543
+ refreshCostCells([node])
2544
+ }
2545
+ }, [refreshCostCells])
2546
+
2547
+ const refreshCostColumn = useCallback(async () => {
2548
+ const nodes = []
2549
+ gridRef.current?.forEachNode((node) => nodes.push(node))
2550
+ nodes.forEach((node) => loadingCellsRef.current.add(node.data.id + ':cost'))
2551
+ gridRef.current?.refreshCells({ columns: ['cost'], force: true }) // rowNodes 생략 = 컬럼 단위
2552
+ await Promise.all(nodes.map((node) => fetchCost(node)))
2553
+ }, [fetchCost])
2554
+
2555
+ const CostCell = useCallback((params) => {
2556
+ const value = <span>{formatCost(params.value)}</span>
2557
+ const key = params.data.id + ':' + params.colDef.field
2558
+ if (loadingCellsRef.current.has(key)) return <CellProgress>{value}</CellProgress>
2559
+ if (errorCellsRef.current.has(key)) {
2560
+ // 버튼을 넣으면 행 높이가 바뀜 — 인라인 링크로
2561
+ return (
2562
+ <Box component="span" sx={(theme) => ({ display: 'inline-flex', alignItems: 'center', gap: '6px', color: theme.palette.error.main })}>
2563
+ 불러오기 실패
2564
+ <OpsnowCommonLink color="primary" sx={{ cursor: 'pointer' }} onClick={() => fetchCost(params.node)}>재시도</OpsnowCommonLink>
2565
+ </Box>
2566
+ )
2567
+ }
2568
+ return value
2569
+ }, [fetchCost])
2570
+
2571
+ const RecalcButtonCell = useCallback((params) => (
2572
+ <OpsnowCommonButton label="재계산" size="small" variant="outlined" color="secondary" onClick={() => fetchCost(params.node)} />
2573
+ ), [fetchCost])
2574
+
2575
+ // useMemo 로 고정 — 매 렌더마다 새로 만들면 그리드가 통째로 다시 그려진다
2576
+ const columnDefs = useMemo(() => [
2577
+ { headerName: '계정', field: 'account', flex: 1, minWidth: 140 },
2578
+ { headerName: '서비스', field: 'service', flex: 1, minWidth: 160 },
2579
+ { headerName: '비용', field: 'cost', flex: 1, minWidth: 200, cellRenderer: CostCell },
2580
+ { headerName: '', colId: 'action', width: 110, sortable: false, cellRenderer: RecalcButtonCell },
2581
+ ], [CostCell, RecalcButtonCell])
2582
+
2583
+ const gridOptions = useMemo(() => ({
2584
+ domLayout: 'autoHeight',
2585
+ suppressCellFocus: true,
2586
+ animateRows: true,
2587
+ }), [])
2588
+ `,
2589
+ code: `<>
2590
+ <div style={{ marginBottom: '16px' }}>
2591
+ <OpsnowCommonButton label="비용 컬럼 새로고침 (컬럼 전체)" size="large" variant="outlined" color="secondary" onClick={refreshCostColumn} />
2592
+ </div>
2593
+ <OpsnowCommonDataGrid
2594
+ ref={gridRef}
2595
+ langCd={i18n.getLocale()}
2596
+ columnDefs={columnDefs}
2597
+ rowData={rowData}
2598
+ status={status}
2599
+ gridOptions={gridOptions}
2600
+ />
2601
+ </>`
2602
+ },
2603
+ {
2604
+ title: '행 단위 로딩 (Partial Loading — rowClassRules + redrawRows) + 선택 행 일괄 로딩',
2605
+ description: '"이 행만 동기화 중" 같은 행 단위 부분 로딩 예제입니다. 로딩 중인 행 id를 useRef(Set)에 담고, gridOptions.rowClassRules로 그 행에 클래스를 붙여 dim + 클릭 차단하며, ref API redrawRows({ rowNodes })로 해당 행만 다시 그리면 rowClassRules가 재평가됩니다. 인디케이터는 컬럼 렌더러를 감싸 행 전체 컬럼(버튼 컬럼 포함)에 적용해야 로딩 중 행 높이가 유지됩니다. 여러 행(체크박스 선택)을 켤 때는 rowNodes 배열로 묶어 redrawRows를 한 번만 호출하고, 끌 때는 응답이 온 행부터 개별 해제합니다. 선택 노드는 forEachNode + node.isSelected()로 수집하고(getSelectedNodes는 ref API에 없음), onSelectionChanged는 gridOptions가 아니라 top-level prop으로 넘겨야 호출됩니다(인자 = 선택된 노드 배열).',
2606
+ code_props_usage: `
2607
+ import { useCallback, useMemo, useRef, useState } from 'react'
2608
+ import Box from '@mui/material/Box'
2609
+ import { useCommonComponents, useGlobalContext } from '@opsnow-common/opsnow-finops-common-ui-loader'
2610
+ import i18n from '@opsnow-common/opsnow-finops-common-i18n'
2611
+
2612
+ // '셀 단위 로딩' 예제와 동일
2613
+ const CellProgress = ({ children }) => {
2614
+ const { OpsnowCommonLoading } = useCommonComponents()
2615
+ return (
2616
+ <Box sx={{ position: 'relative', display: 'inline-flex', alignItems: 'center', minWidth: '18px', minHeight: '20px' }}>
2617
+ <Box aria-hidden sx={{ visibility: 'hidden' }}>{children}</Box>
2618
+ <Box sx={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center' }}>
2619
+ <OpsnowCommonLoading size={18} />
2620
+ </Box>
2621
+ </Box>
2622
+ )
2623
+ }
2624
+
2625
+ const fakeApi = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
2626
+ const nowLabel = () => new Date().toLocaleTimeString()
2627
+
2628
+ const { OpsnowCommonDataGrid, OpsnowCommonButton } = useCommonComponents()
2629
+ const { CommonConst } = useGlobalContext()
2630
+ const gridRef = useRef(null)
2631
+
2632
+ const [rowData] = useState([
2633
+ { id: 'r1', account: 'opsnow-prod', service: 'Amazon EC2', status: '동기화 완료' },
2634
+ { id: 'r2', account: 'opsnow-dev', service: 'Amazon S3', status: '동기화 완료' },
2635
+ { id: 'r3', account: 'opsnow-stg', service: 'Amazon RDS', status: '동기화 완료' },
2636
+ ])
2637
+ const status = CommonConst.GRID_STATUS.DATA_EXISTS
2638
+
2639
+ const loadingRowsRef = useRef(new Set())
2640
+ const [selectedCount, setSelectedCount] = useState(0)
2641
+ const [running, setRunning] = useState(false)
2642
+
2643
+ const setRowLoading = useCallback((node, loading) => {
2644
+ if (loading) loadingRowsRef.current.add(node.data.id)
2645
+ else loadingRowsRef.current.delete(node.data.id)
2646
+ gridRef.current?.redrawRows({ rowNodes: [node] }) // rowClassRules 재평가
2647
+ }, [])
2648
+
2649
+ const refreshRow = useCallback(async (node) => {
2650
+ setRowLoading(node, true)
2651
+ try {
2652
+ await fakeApi(1800)
2653
+ node.setDataValue('status', '동기화 ' + nowLabel())
2654
+ } finally {
2655
+ setRowLoading(node, false)
2656
+ }
2657
+ }, [setRowLoading])
2658
+
2659
+ const syncSelectedRows = useCallback(async () => {
2660
+ const nodes = []
2661
+ gridRef.current?.forEachNode((node) => { if (node.isSelected()) nodes.push(node) }) // getSelectedNodes 없음
2662
+ if (nodes.length === 0) return
2663
+ setRunning(true)
2664
+ nodes.forEach((node) => loadingRowsRef.current.add(node.data.id))
2665
+ gridRef.current?.redrawRows({ rowNodes: nodes }) // 묶어서 한 번만
2666
+ await Promise.all(nodes.map(async (node, index) => {
2667
+ await fakeApi(1000 + index * 600)
2668
+ node.setDataValue('status', '동기화 ' + nowLabel())
2669
+ loadingRowsRef.current.delete(node.data.id)
2670
+ gridRef.current?.redrawRows({ rowNodes: [node] })
2671
+ }))
2672
+ setRunning(false)
2673
+ }, [])
2674
+
2675
+ // defaultColDef 는 마운트 시점에 굳으므로 컬럼 렌더러를 감싼다
2676
+ const withRowLoading = useCallback((Renderer) => {
2677
+ const Wrapped = (params) => {
2678
+ const content = Renderer ? <Renderer {...params} /> : <span>{params.value}</span>
2679
+ return loadingRowsRef.current.has(params.data?.id) ? <CellProgress>{content}</CellProgress> : content
2680
+ }
2681
+ return Wrapped
2682
+ }, [])
2683
+
2684
+ const RefreshButtonCell = useCallback((params) => (
2685
+ <OpsnowCommonButton label="행 새로고침" size="small" variant="outlined" color="secondary" onClick={() => refreshRow(params.node)} />
2686
+ ), [refreshRow])
2687
+
2688
+ // 감싼 렌더러는 useMemo 안에서 한 번만 만든다
2689
+ const columnDefs = useMemo(() => {
2690
+ const valueCell = withRowLoading()
2691
+ return [
2692
+ { headerName: '계정', field: 'account', flex: 1, minWidth: 140, cellRenderer: valueCell },
2693
+ { headerName: '서비스', field: 'service', flex: 1, minWidth: 160, cellRenderer: valueCell },
2694
+ { headerName: '상태', field: 'status', flex: 1, minWidth: 180, cellRenderer: valueCell },
2695
+ // 버튼 컬럼도 감싸야 행 높이가 유지된다
2696
+ { headerName: '', colId: 'action', width: 140, sortable: false, cellRenderer: withRowLoading(RefreshButtonCell) },
2697
+ ]
2698
+ }, [withRowLoading, RefreshButtonCell])
2699
+
2700
+ const gridOptions = useMemo(() => ({
2701
+ domLayout: 'autoHeight',
2702
+ suppressCellFocus: true,
2703
+ animateRows: true,
2704
+ rowSelection: { mode: 'multiRow', checkboxes: true, headerCheckbox: true, enableClickSelection: false },
2705
+ rowClassRules: {
2706
+ 'row-loading': (params) => loadingRowsRef.current.has(params.data?.id),
2707
+ },
2708
+ }), [])
2709
+
2710
+ // gridOptions 가 아니라 top-level prop — 인자는 선택된 노드 배열
2711
+ const handleSelectionChanged = useCallback((selectedNodes) => {
2712
+ setSelectedCount(selectedNodes.length)
2713
+ }, [])
2714
+ `,
2715
+ code: `<Box sx={{ '& .ag-row.row-loading': { opacity: 0.45, pointerEvents: 'none' } }}>
2716
+ <div style={{ marginBottom: '16px' }}>
2717
+ <OpsnowCommonButton
2718
+ label={'선택 행 동기화 (' + selectedCount + ')'}
2719
+ size="large"
2720
+ variant="outlined"
2721
+ color="secondary"
2722
+ disabled={selectedCount === 0 || running}
2723
+ onClick={syncSelectedRows}
2724
+ />
2725
+ </div>
2726
+ <OpsnowCommonDataGrid
2727
+ ref={gridRef}
2728
+ langCd={i18n.getLocale()}
2729
+ columnDefs={columnDefs}
2730
+ rowData={rowData}
2731
+ status={status}
2732
+ gridOptions={gridOptions}
2733
+ onSelectionChanged={handleSelectionChanged}
2734
+ />
2735
+ </Box>`
2736
+ },
2479
2737
  ];
@@ -308,10 +308,11 @@ export const CurrencySwitcherSchema = z.object({
308
308
  labels: z.string().optional().describe("문구 개별 커스텀 객체 변수명 — Partial<CurrencySwitcherLabels>, 우선순위 labels > 앱 i18n 리소스(common.currency_switcher.*) > 패키지 내장 ko/en/ja ({placeholder} 템플릿 치환 지원). 기준 통화 행 문구는 baseCurrencyDescription, 히스토리 테이블 첫 컬럼 머리는 payerColumnLabel 키로 교체"),
309
309
  size: z.enum(["small", "medium"]).optional().describe("트리거 크기"),
310
310
  disabled: z.boolean().optional().describe("비활성화 여부"),
311
- // 환율 설정(직접 입력 · 시뮬레이션) 영역 props — dropdown 컴포넌트 쪽. 대상 통화는 항상 현재 선택 통화(value)
312
- customRate: z.string().optional().describe("직접 입력 시뮬레이션 환율 상태 변수명 (number | null, controlled 모드 — 미지정 시 내부 관리)"),
313
- onCustomRateChange: z.string().optional().describe("직접 입력 환율 변경 핸들러 함수명 — (rate, context) => void 형태, 저장되지 않는 what-if 값 통지 (onChange는 호출되지 않음)"),
314
- showDetail: z.boolean().optional().describe("환율 설정(직접 입력 시뮬레이션) 섹션 노출 여부 (기본값: false). true여도 선택 통화가 기준 통화면 환산이 없어 노출되지 않음"),
311
+ // 환율 설정(직접 입력 · 시뮬레이션) 영역 props — dropdown 컴포넌트 쪽. 대상(앵커) 통화는 항상 simulationAnchorCurrency
312
+ simulationAnchorCurrency: z.string().optional().describe("시뮬레이션 앵커 통화 코드 — 입력 좌변('1 {anchor} =')의 통화이자 onCustomRateChange context.currency로 통지되는 값. 미지정이면 시뮬레이션 섹션이 노출되지 않음 (showDetail과 무관). 선택 통화와 같을 때도 노출되지 않음 (예: 'KRW')"),
313
+ customRate: z.string().optional().describe("직접 입력 시뮬레이션 환율 상태 변수명 (number | null, controlled) — 앵커 통화 1단위당 값. number면 입력에 표시, null이면 빈 입력(placeholder 없음). 팝오버를 열 때 이 값으로 입력이 동기화됨"),
314
+ onCustomRateChange: z.string().optional().describe("직접 입력 환율 확정 핸들러 함수명 — (rate, context) => void 형태. 적용(또는 Enter) 시 입력값, 초기화·통화 변경 시 null로 호출되고 입력 중에는 호출되지 않음. context.currency는 항상 simulationAnchorCurrency. 저장되지 않는 what-if 값 통지 (onChange는 호출되지 않음)"),
315
+ showDetail: z.boolean().optional().describe("환율 설정(직접 입력 시뮬레이션) 섹션 노출 여부 (기본값: false). 실제 노출은 showDetail && simulationAnchorCurrency 지정 && simulationAnchorCurrency !== 선택 통화 3조건으로 판정 — 선택 통화가 기준 통화(USD)여도 앵커와 다르면 노출됨"),
315
316
  });
316
317
  // Forms 컴포넌트 함수 - 배열 반환
317
318
  export function createFormsComponent() {
@@ -1149,6 +1150,14 @@ export function createFormsComponent() {
1149
1150
  - 통화 선택 시 onChange(currency, rate)가 호출됩니다 — 금액 표시 변환은 소비 프로젝트에서 rate로 처리
1150
1151
  - 문구는 i18n 현재 언어(ko/en/ja)를 자동으로 따르고, labels prop으로 항목별 커스텀 가능 (우선순위: labels > 앱 i18n 리소스 common.currency_switcher.* > 패키지 내장 문구. 기준 통화 행 문구는 baseCurrencyDescription, 히스토리 테이블 첫 컬럼 머리는 payerColumnLabel 키)
1151
1152
 
1153
+ **환율 설정(직접 입력 · 시뮬레이션) 규칙 (2.0.11+):**
1154
+ - 노출 판정은 showDetail && simulationAnchorCurrency 지정 && simulationAnchorCurrency !== 선택 통화 3조건뿐입니다 — '선택 통화가 USD면 미노출' 같은 내부 고정 규칙은 없어 USD 선택 중에도 앵커가 다르면 노출됩니다
1155
+ - 입력 라벨은 '1 {simulationAnchorCurrency} = [입력] {선택 통화}' — 좌변이 앵커 통화, 우변이 현재 선택 통화입니다. 입력창에 현재 환율 placeholder(회색 숫자)는 표시되지 않습니다
1156
+ - onCustomRateChange(rate, context)의 context.currency는 적용·초기화·통화 변경 3곳 모두 항상 simulationAnchorCurrency입니다. 적용(또는 Enter) 시 입력값, 초기화·통화 변경 시 null로 호출되고 입력 중에는 호출되지 않습니다
1157
+ - customRate는 앵커 통화 1단위당 값(number | null, controlled)이며 팝오버를 열 때 입력이 이 값으로 동기화됩니다
1158
+ - defaultTargetCurrency · simulationTarget · historyMode는 @deprecated로 무시됩니다 — 앵커 통화는 simulationAnchorCurrency로 지정하세요
1159
+ - showDetail 기본값은 false — 시뮬레이션 props를 쓰지 않는 소비자는 아무 변화가 없습니다
1160
+
1152
1161
  **공통 헤더에서 쓰기 (OpsnowFinopsCommonHeader):**
1153
1162
  - 실서비스 배치는 **무조건 공통 헤더를 통해서** 합니다 — 페이지 본문에 단독 배치하지 마세요 (단독 예제는 props 사용법 참고용)
1154
1163
  - 헤더는 AI 버튼~알림 벨 사이 자리만 제공하고 상태·환율 데이터를 갖지 않습니다 — 앱이 관리하는 값을 currencySwitcherProps로 주입하면 내부 OpsnowCommonCurrencySwitcher에 그대로 전달됩니다 (위 props 규칙 동일 적용) - **활성/비활성 정책**: 통화 변경은 '개요(OverView)' · '비용 분석(Analytics)' · '청구 내역(Billing Invoice)' · '비용 배분(Cost Allocation)' 4개 메뉴에서만 허용됩니다 — 그 외 메뉴 진입 시 currencySwitcherEnabled={false}로 넘겨 버튼을 disabled 상태로 노출하고, 호버 시 안내 툴팁이 표시됩니다 (문구는 내장 ko/en/ja 기본, currencySwitcherDisabledTooltip으로 덮어쓰기 가능). 적용 라우트: '/overview' · '/cost/analytics/usage-charges' · '/cost/billing-invoice' · '/settings/cost-allocation'(하위 경로 포함) — 현재 라우트가 여기에 속하는지를 앱에서 판단해 주입하세요
@@ -1197,6 +1206,8 @@ export function createFormsComponent() {
1197
1206
  entries.push(["baseCurrency", `'${args.baseCurrency}'`]);
1198
1207
  if (args.nativeCurrency)
1199
1208
  entries.push(["nativeCurrency", `'${args.nativeCurrency}'`]);
1209
+ if (args.simulationAnchorCurrency)
1210
+ entries.push(["simulationAnchorCurrency", `'${args.simulationAnchorCurrency}'`]);
1200
1211
  if (args.customRate)
1201
1212
  entries.push(["customRate", args.customRate]);
1202
1213
  if (args.onCustomRateChange)
@@ -30,6 +30,7 @@ const gridOptionsSchema = z.object({
30
30
  serverSideSortAllLevels: z.boolean().optional().describe('서버사이드 전체 레벨 정렬'),
31
31
  getRowId: z.string().optional().describe('행 ID 반환 함수(stringified)'),
32
32
  suppressMenuHide: z.boolean().optional().describe('메뉴 숨김 억제'),
33
+ rowClassRules: z.record(z.string()).optional().describe('행 클래스 규칙 { 클래스명: (params) => boolean (stringified) }. redrawRows({ rowNodes }) 호출 시 재평가됨 — 행 단위 로딩(dim/클릭 차단) 표현에 사용. \'셀·행 단위 로딩\' 예제 참고'),
33
34
  });
34
35
  const columnDefsSchema = z.lazy(() => z.object({
35
36
  headerName: z.string().optional().describe('컬럼 헤더명'),
@@ -131,6 +132,8 @@ export const DataGridSchema = z.object({
131
132
  rowData: z.array(z.record(z.any())).optional().describe('그리드에 표시할 데이터'),
132
133
  status: z.number().describe(`그리드 상태값: GRID.STATUS.LOADING(0), GRID.STATUS.DATA_EXISTS(1), GRID.STATUS.ERROR(9) 중 하나를 사용해야 합니다.
133
134
 
135
+ ⚠️ LOADING은 그리드 **전체**를 가리는 로딩입니다. "이 셀만/이 행만 갱신 중" 같은 부분 로딩은 status로 표현할 수 없습니다 — status는 DATA_EXISTS로 두고, ref API(refreshCells/redrawRows) + 로딩 키 Set(useRef) 조합으로 구현합니다. '셀 단위 로딩' / '행 단위 로딩' 예제 참고.
136
+
134
137
  **필수 구현 패턴:**
135
138
  반드시 useState로 상태를 관리하고, useEffect 등을 사용하여 데이터 로딩 로직을 구현해야 합니다.
136
139
  사용자는 이 상태 제어 코드 안에 실제 비즈니스 로직(API 호출, 데이터 처리 등)을 추가할 수 있습니다.
@@ -190,7 +193,7 @@ export const DataGridSchema = z.object({
190
193
  onGridReady: z.string().optional().describe('그리드 준비 이벤트 핸들러(stringified)'),
191
194
  onFirstDataRendered: z.string().optional().describe('첫 데이터 렌더링 이벤트 핸들러(stringified)'),
192
195
  onGridSizeChanged: z.string().optional().describe('그리드 크기 변경 이벤트 핸들러(stringified)'),
193
- onSelectionChanged: z.string().optional().describe('선택 변경 이벤트 핸들러(stringified)'),
196
+ onSelectionChanged: z.string().optional().describe('선택 변경 이벤트 핸들러(stringified). 반드시 gridOptions가 아니라 이 top-level prop으로 넘겨야 호출됨 (래퍼가 AG Grid 이벤트를 내부에서 잡고 이 prop을 부름). 인자는 이벤트 객체가 아니라 선택된 노드 배열: (selectedNodes) => setCount(selectedNodes.length)'),
194
197
  onCellClick: z.string().optional().describe('셀 클릭 이벤트 핸들러(stringified)'),
195
198
  onFilterChanged: z.string().optional().describe('필터 변경 이벤트 핸들러(stringified)'),
196
199
  onUpdateFilter: z.string().optional().describe('필터 업데이트 이벤트 핸들러(stringified)'),
@@ -252,6 +255,18 @@ export function createDataGridComponent() {
252
255
  // 10행 초과 시에만 고정 높이 부여 (autoHeight 셀은 가변이라 픽셀 고정값 사용)
253
256
  <OpsnowCommonDataGrid gridOptions={gridOptions} gridHeight={isScroll ? 600 : undefined} />
254
257
 
258
+ **셀·행 단위 부분 로딩 (Partial Loading) — status로 하지 말 것**
259
+ status={GRID_STATUS.LOADING}은 그리드 전체를 가립니다. "이 셀만 재계산 중", "선택한 행만 동기화 중" 같은 부분 로딩은
260
+ status는 DATA_EXISTS로 유지하고 아래 조합으로 앱에서 구현합니다 ('셀 단위 로딩' / '행 단위 로딩' 예제 전문 참고):
261
+ 1. 로딩 중인 키는 useState가 아니라 useRef(new Set()) 에 담는다 — state로 두면 그리드 전체 리렌더로 스크롤이 튄다
262
+ 2. 셀 단위: gridRef.current.refreshCells({ rowNodes, columns, force: true }) — 값이 그대로라 force: true 필수. rowNodes 생략 시 컬럼 전체
263
+ 3. 행 단위: gridOptions.rowClassRules + gridRef.current.redrawRows({ rowNodes }) — redrawRows 시 rowClassRules 재평가. 여러 행은 rowNodes 배열로 묶어 한 번만 호출
264
+ 4. 인디케이터로 셀 콘텐츠를 치환하지 말고, 원래 콘텐츠를 visibility: hidden 으로 남기고 인디케이터를 position: absolute 로 겹친다 (autoHeight라 치환하면 행 높이가 바뀜)
265
+ 5. columnDefs/셀 렌더러는 useMemo/useCallback 으로 고정 — 매 렌더마다 새로 만들면 그리드가 통째로 다시 그려진다
266
+ 6. 선택 노드 수집: forEachNode + node.isSelected() (getSelectedNodes는 ref API에 없음). onSelectionChanged는 top-level prop (인자 = 선택 노드 배열)
267
+ ref API 요약: refreshCells / redrawRows / forEachNode / node.setDataValue(field, value) / downloadExcel / downloadCsv
268
+ rowModelType: 'serverSide' 그리드는 AG Grid 내장 loadingCellRenderer를 쓰는 것이 맞습니다.
269
+
255
270
  **import:**
256
271
  \`\`\`javascript
257
272
  import { useCommonComponents, useGlobalContext } from '@opsnow-common/opsnow-finops-common-ui-loader';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opsnow-mcp/opsnow-mcp-common-ui-server",
3
- "version": "1.0.38",
3
+ "version": "1.0.40",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "bin": {