sellmate-design-system-react 9.0.0-beta.61 → 9.0.0-beta.63

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.
Files changed (48) hide show
  1. package/AGENTS.md +510 -136
  2. package/dist/components/SBarChart/README.md +15 -0
  3. package/dist/components/SBarChart/SBarChart.d.ts +15 -1
  4. package/dist/components/SBarChart/index.d.ts +1 -1
  5. package/dist/components/SCallout/README.md +1 -0
  6. package/dist/components/SCallout/SCallout.d.ts +6 -1
  7. package/dist/components/SDonutChart/README.md +2 -4
  8. package/dist/components/SDonutChart/SDonutChart.d.ts +9 -10
  9. package/dist/components/SGhostButton/README.md +2 -0
  10. package/dist/components/SLineChart/README.md +15 -0
  11. package/dist/components/SLineChart/SLineChart.d.ts +15 -1
  12. package/dist/components/SLineChart/index.d.ts +1 -1
  13. package/dist/components/SLineChart/lineChart.config.d.ts +8 -5
  14. package/dist/components/SMeatballButton/README.md +67 -0
  15. package/dist/components/SMeatballButton/SMeatballButton.d.ts +36 -0
  16. package/dist/components/SMeatballButton/index.d.ts +1 -0
  17. package/dist/components/SPage/README.md +19 -0
  18. package/dist/components/SPage/SPage.d.ts +11 -2
  19. package/dist/components/SPage/index.d.ts +1 -1
  20. package/dist/components/SPage/page.config.d.ts +8 -0
  21. package/dist/components/SPieChart/README.md +1 -0
  22. package/dist/components/SPieChart/SPieChart.d.ts +6 -1
  23. package/dist/docs/RecipeGallery.d.ts +13 -0
  24. package/dist/index.cjs +338 -154
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.js +336 -154
  28. package/dist/index.js.map +1 -1
  29. package/dist/lib/chart/radial.d.ts +22 -11
  30. package/dist/lib/chart/tooltip.d.ts +18 -1
  31. package/dist/lib/dropdown-menu.d.ts +19 -0
  32. package/dist/llms-full.txt +628 -140
  33. package/dist/llms.txt +512 -138
  34. package/dist/recipes/AppShell.d.ts +21 -0
  35. package/dist/recipes/ExcelFilePopupPage.d.ts +5 -0
  36. package/dist/recipes/LoginPage.d.ts +6 -0
  37. package/dist/recipes/ProductCreatePage.d.ts +1 -0
  38. package/dist/recipes/ProductCreateSectionsPage.d.ts +5 -0
  39. package/dist/recipes/ProductDetailInlineEditPage.d.ts +9 -0
  40. package/dist/recipes/ProductDetailPage.d.ts +1 -0
  41. package/dist/recipes/ProductListChipFilterPage.d.ts +1 -0
  42. package/dist/recipes/ProductListPage.d.ts +1 -0
  43. package/dist/recipes/TransferOrderPopupPage.d.ts +2 -0
  44. package/dist/recipes/api.d.ts +91 -0
  45. package/dist/recipes/recipe-story.d.ts +33 -0
  46. package/dist/recipes/router.d.ts +9 -0
  47. package/dist/styles.css +21 -3
  48. package/package.json +3 -1
package/AGENTS.md CHANGED
@@ -39,13 +39,13 @@
39
39
 
40
40
  | 분류 | 컴포넌트 |
41
41
  | --- | --- |
42
- | **버튼·링크** | `SButton` `SGhostButton` `SDropdownButton` `STextLink` `SSwitch` `SToggle` |
42
+ | **버튼·링크** | `SButton` `SGhostButton` `SMeatballButton`(⋯ 로 '더 보기' 메뉴를 여는 아이콘 버튼) `SDropdownButton` `STextLink` `SSwitch` `SToggle` |
43
43
  | **입력 (폼)** | `SForm` `SField` `SInput` `SChatInput`(대화 입력창) `SSearchInput` `SNumberInput` `STextarea` `SEditor` `SSelect` `SCheckbox` `SRadio` `SRadioGroup` `SRadioButton` `SRadioCard` `SRadioCardGroup` `SChip` `SChipInput` `SBarcodeInput` `SFilePicker` |
44
44
  | **날짜·시간** | `SCalendar` `SCalendarBoard`(한 달치 일정 판) `SDatePicker` `SDatePickerYearListbox` `SDatePickerMonthListbox` `SDateRangePicker` `STimePicker` `STimeRangePicker` |
45
45
  | **표·목록** | `SChatMessage`(대화의 메시지 한 건) `SChatAttachedFile`(입력창 위, 아직 보내지 않은 첨부 한 칸) `SChatFile`(대화 흐름에 선, 보낸 파일 한 칸) `SChatSystemMessage`(대화 흐름 가운데 서는 시스템 안내) `STable` `STableBar` `SChipFilter` `SKeyValueTable` `SList` `SListItem` `SExpansionList` `SDraggableList` `SDraggableItem` `STree` |
46
46
  | **레이아웃** | `SLayout` `SGnb` `SGnbSystem` `SSystemActionButton`(GNB system 패널에 한 줄씩 쌓는 액션 행 — 버튼 고르기는 §3-5) `SAccountListBox`(계정 행을 눌러 뜨는 계정 패널) `SLauncherListBox`(런처 버튼을 눌러 뜨는 서비스 목록) `SPage` `SPageHeader`(페이지 제목 영역 — `SLayout` 안에서 `SPage` 앞에 둔다) `SSectionHeaderCard` `SCard` `SLoginCard`(통합 계정 로그인 화면의 카드) `SDivider` `SSplitter` `SScrollArea` `SExpansionItem` |
47
47
  | **내비게이션** | `STabs` `SPagination` `SStepper` |
48
- | **차트** | `SBarChart`(막대 그래프 — 항목끼리 크기를 견준다. `stacked` 로 항목 안의 구성까지) `SLineChart`(꺾은선 그래프 — 순서가 있는 항목의 추이를 본다. `area` 로 크기까지, `stacked` 로 구성까지) `SPieChart`(원그래프 — 한 시점의 전체를 몇 개의 몫으로 나눈다) `SDonutChart`(도넛 그래프 — 몫과 함께 가운데에 합계를 적는다) |
48
+ | **차트** | `SBarChart`(막대 그래프 — 항목끼리 크기를 견준다. `stacked` 로 항목 안의 구성까지) `SLineChart`(꺾은선 그래프 — 순서가 있는 항목의 추이를 본다. `area` 로 크기까지, `stacked` 로 구성까지) `SPieChart`(원그래프 — 한 시점의 전체를 몇 개의 몫으로 나눈다) `SDonutChart`(도넛 그래프 — 가운데가 빈 원그래프. 구멍에 글자를 두지 않는다) |
49
49
  | **표시·상태** | `STag` `SBadge` `SIcon` `SLogo`(브랜드 로고를 아이콘처럼 — size 는 높이다) `SImage` `SCallout` `SGuide` |
50
50
  | **진행·로딩** | `SLinearProgress` `SCircleProgress` `SLoadingContainer` `SLoadingModal` |
51
51
  | **오버레이** | `STooltip` `SPopover` `SPopup` `SDrawer` `SPortal` |
@@ -72,7 +72,7 @@ AI 에이전트는 코드를 생성하기 전에 이 목록을 반드시 지킨
72
72
 
73
73
  | 금지 | 대신 사용 |
74
74
  | --- | --- |
75
- | `<button>` | `SButton`, `SGhostButton`, `SDropdownButton`, `STextLink` |
75
+ | `<button>` | `SButton`, `SGhostButton`, `SMeatballButton`, `SDropdownButton`, `STextLink` |
76
76
  | `<input type="text/password/...">` | `SInput` |
77
77
  | `<input type="search">` | `SSearchInput` |
78
78
  | `<input type="number">` | `SNumberInput` |
@@ -168,7 +168,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
168
168
  | --- | --- | --- |
169
169
  | **셸** | 앱 전체 뼈대. 페이지가 바뀌어도 남는다 | `SLayout` `SGnb` `SGnbSystem`(GNB 맨 아래 판 · 전폭 상단바 오른쪽 끝) `SPage` `SPageHeader`(`SLayout` 안에서 `SPage` 앞에 둔다) |
170
170
  | **블록** | `SPage` 의 직계 자식. 페이지를 세로로 쌓는 단위 | `SSectionHeaderCard` `SCard` `SChatMessage` `SChatSystemMessage` `SChatInput` `SLoginCard`(유일하게 `SPage` 밖에 선다 — 로그인 화면 자체가 자기 자리다, §4-6) `SForm` `SSplitter` `SScrollArea` `SCalendarBoard` `STable` `STableBar` `SChipFilter` `SKeyValueTable` `SList` `SExpansionList` `SDraggableList` `STree` `SCallout` `SBarChart` `SLineChart` `SPieChart` `SDonutChart` `STabs` `SStepper` `SPagination` `SDivider` |
171
- | **요소** | 블록 **안에** 놓이는 컨트롤. 혼자 페이지에 서지 않는다 | `SButton` `SGhostButton` `SDropdownButton` `SSystemActionButton` `SField` `SInput` `SSearchInput` `SNumberInput` `STextarea` `SEditor` `SSelect` `SCheckbox` `SRadio` `SRadioGroup` `SRadioButton` `SRadioCard` `SRadioCardGroup` `SSwitch` `SToggle` `SChipInput` `SBarcodeInput` `SFilePicker` `SDatePicker` `SDatePickerYearListbox` `SDatePickerMonthListbox` `SDateRangePicker` `STimePicker` `STimeRangePicker` `SCalendar` `SListItem` `SExpansionItem` `SDraggableItem` `SChatAttachedFile` `SChatFile` `SImage` `SLinearProgress` `SCircleProgress` |
171
+ | **요소** | 블록 **안에** 놓이는 컨트롤. 혼자 페이지에 서지 않는다 | `SButton` `SGhostButton` `SMeatballButton` `SDropdownButton` `SSystemActionButton` `SField` `SInput` `SSearchInput` `SNumberInput` `STextarea` `SEditor` `SSelect` `SCheckbox` `SRadio` `SRadioGroup` `SRadioButton` `SRadioCard` `SRadioCardGroup` `SSwitch` `SToggle` `SChipInput` `SBarcodeInput` `SFilePicker` `SDatePicker` `SDatePickerYearListbox` `SDatePickerMonthListbox` `SDateRangePicker` `STimePicker` `STimeRangePicker` `SCalendar` `SListItem` `SExpansionItem` `SDraggableItem` `SChatAttachedFile` `SChatFile` `SImage` `SLinearProgress` `SCircleProgress` |
172
172
  | **인라인** | 텍스트 흐름·셀·라벨 안에 섞인다. 혼자 블록이 되지 않는다 | `STag` `SBadge` `SIcon` `SLogo` `STextLink` `SChip` |
173
173
  | **레이어** | 문서 흐름 **밖**에 떠서 그려진다. 어느 층에서 띄우든 레이아웃에 영향이 없다 | `SModal` `SActionModal` `SConfirmModal` `SPopup` `SDrawer` `SPopover` `STooltip` `SPortal` `SAccountListBox`(계정 행에 붙어 뜬다 — 직접 띄우지 않는다) `SLauncherListBox`(런처 버튼에 붙어 뜬다 — 직접 띄우지 않는다) `SToast` `SLoadingModal` `SLoadingContainer` `SGuide` |
174
174
 
@@ -308,6 +308,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
308
308
  | 상황 | 값 |
309
309
  | --- | --- |
310
310
  | **페이지 콘텐츠 패딩** | **`SPage` 가 `--cmp-pageBody-padding-default` 로 이미 넣는다.** 직접 주지 않는다 (덮어쓰면 토큰이 바뀌어도 안 따라간다). `SPage` 밖에서 같은 패딩이 필요하면 `p-sd-16` |
311
+ | **가장자리까지 차는 블록이 맨 위에 서는 페이지** (`SChipFilter` 바 등) | `SPage` 에 `padding="none"` 으로 끄고, **그 아래 블록이 `p-[var(--cmp-pageBody-padding-default)]` 로 같은 토큰의 여백을 잡는다.** `className="p-0"` 이나 `p-sd-16` 으로 적지 않는다 (§4-2 칩 필터) |
311
312
  | **섹션 ↔ 섹션**, **블록 ↔ 블록** (헤더·필터·본문 사이) | **`gap-sd-12`** |
312
313
  | **툴바 ↔ 그 표** (`STableBar` ↔ `STable`) | **간격 없음 — 붙인다.** 둘은 한 블록이다 (§2-0) |
313
314
  | 요소 ↔ 요소 | **기본 `gap-sd-8`** (`--sys-space-stack-gap-element-normal`) · 타이트 `gap-sd-4`(`-tight`) · 여유 `gap-sd-12`(`-relaxed`) / `gap-sd-16`(`-wide`) |
@@ -325,7 +326,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
325
326
  | `SCheckbox` | **`gap-sd-24`** | `gap-sd-8` |
326
327
  | `SRadio` | **`gap-sd-24`** | `gap-sd-8` |
327
328
  | `STextLink` | **`gap-sd-16`**(sm) / **`gap-sd-24`**(md·lg) | `gap-sd-4` |
328
- | `SGhostButton` | `gap-sd-4` | `gap-sd-4` |
329
+ | `SGhostButton` · `SMeatballButton` | `gap-sd-4` | `gap-sd-4` |
329
330
  | `SButton` | `gap-sd-8` (xs·sm·md) / **`gap-sd-12`**(lg) | 〃 |
330
331
  | `STag` | `gap-sd-8` | `gap-sd-8` |
331
332
  | `SToggle` | `gap-sd-8` | `gap-sd-8` |
@@ -407,7 +408,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
407
408
  ```
408
409
 
409
410
  - **`bordered={false}`** 로 표의 테두리를 끈다. 카드가 이미 그린다.
410
- - **`radius="useTop"`** 으로 위쪽 모서리를 죽인다. 아래쪽 라운드는 카드가 처리한다.
411
+ - **`radius="useTop"`** 으로 위쪽 모서리를 죽인다. 아래쪽 라운드는 카드가 처리한다. **표 아래에 카드 푸터(`button`·`footerLeft`)가 오면 `radius="full"`** — 아래 모서리도 카드가 아니라 푸터의 상단 구분선이 이어받으므로 네 모서리를 모두 죽인다.
411
412
  - `STable` 은 **페이지네이션 바의 테두리와 `-mt-px` 겹침도 함께 꺼진다** — 본문 테두리가 없으면 겹칠 대상이 없어, 그대로 두면 페이지네이션만 테두리를 갖고 1px 어긋난다.
412
413
 
413
414
  #### 본문 바탕 눌러앉히기 (선택)
@@ -571,7 +572,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
571
572
  | 여러 항목의 추이를 **합계와 함께** 보여준다 (채널별 매출이 쌓여 전체가 되는 흐름) | `SLineChart stacked area` | 합계가 뜻을 갖는 값일 때만 쌓는다. 음수가 섞이거나 더해서 뜻이 없는 값(비율·평균)은 쌓지 말고 겹쳐 그린다 |
572
573
  | 항목의 크기와 **그 안의 구성**을 함께 보여준다 (주문 상태별 내역, 유입 경로별 몫) | `SBarChart stacked` | 계열끼리 견주는 것이 목적이면 `stacked` 없이 나란히 세운다. 더해서 뜻이 없는 값(비율·평균)은 쌓지 않는다 — 합계 라벨이 거짓말이 된다 |
573
574
  | 한 시점의 **전체를 몇 개의 몫으로** 나눠 보여준다 (채널별 매출 비중, 회원 등급 구성) | `SPieChart` | 조각이 대여섯 개를 넘거나 몫끼리 크기를 견주는 것이 목적이면 `SBarChart` — 사람은 각도보다 길이를 정확히 견준다. 순서가 있는 추이(월별)는 몫이 아니라 흐름이라 `SLineChart`. 0·음수는 그려지지 않으므로 증감은 원으로 나타내지 않는다. 조각은 큰 순으로 넘긴다. 폭은 부모를 따르고 그림은 그 폭의 정사각형이다 — 폭이 필요하면 `className` 으로 폭만 주고 높이는 주지 않는다 (`SDonutChart` 도 같다) |
574
- | 몫과 함께 **전체의 크기(합계)** 를 먼저 읽게 한다 (주문 상태별 구성 + 총 주문 수, 목표 달성률) | `SDonutChart` | 합계가 필요 없으면 `SPieChart`. 가운데 값은 기본이 그려진 조각의 합계이고, 합계가 아닌 대표값(달성률 등)은 `centerValue` 로 준다. 여러 기간에 걸친 구성 변화는 원을 여러 개 늘어놓지 말고 `SBarChart stacked` |
575
+ | 한 시점의 전체를 몫으로 나누되 **가운데가 빈 고리 모양**으로 보여준다 (시안이 도넛일 때) | `SDonutChart` | 쓰임·데이터 규칙은 `SPieChart` 와 같고 모양만 다르다 — 시안이 정한 쪽을 쓴다. **구멍에 합계·라벨을 겹쳐 올리지 않는다.** 합계 같은 값이 필요하면 차트 바깥(카드 제목·요약 값)에 적는다. 여러 기간에 걸친 구성 변화는 원을 여러 개 늘어놓지 말고 `SBarChart stacked` |
575
576
  | 표 위에 건수 요약과 액션을 얹는다 | `STableBar` | 표의 이름도 여기 `title` 슬롯에 넣는다 — 제목만으로 블록을 따로 세우지 않는다. §4-2 |
576
577
  | 상태·분류를 라벨로 찍는다 | `STag` | §3-1 |
577
578
  | 색 점만으로 상태를 찍는다 | `SBadge` | §3-1 |
@@ -586,6 +587,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
586
587
  | --- | --- | --- |
587
588
  | 라벨이 있는 일반 액션을 준다 | `SButton` | §3-5 |
588
589
  | 아이콘 하나로 뜻이 통하는 부가 조작을 준다 | `SGhostButton` | §3-5-5 |
590
+ | 자리가 좁아 부가 조작 여러 개를 ⋯ 뒤 메뉴로 접어 둔다 | `SMeatballButton` | §3-5-5 |
589
591
  | 한 버튼에 여러 선택지를 매단다 | `SDropdownButton` | §3-5-4 |
590
592
  | GNB system 패널에서 한 줄짜리 행 액션을 준다 | `SSystemActionButton` | §3-5-7 |
591
593
 
@@ -685,7 +687,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
685
687
  | --- | --- |
686
688
  | `default` | 흰 면 위 (`SPage background="frame"`) — 옅은 회색 면이라 페이지와 구분된다 |
687
689
  | `white` | 회색 면 위 (`background="neutral"`·`"screen"`) — 그 위에서는 `default` 가 배경에 묻힌다 |
688
- | `danger` | 경고. 좌측에 "주의사항" 타이틀이 붙으므로 단순 안내에는 쓰지 않는다 |
690
+ | `danger` | 경고. 좌측에 "주의사항" 타이틀이 붙으므로 단순 안내에는 쓰지 않는다. 주변에 이미 경고 맥락(제목 등)이 있어 타이틀이 중복되면 `showTitle={false}` 로 타이틀만 뺀다 — 경고 면은 유지된다 |
689
691
 
690
692
  ### 3-3. 플로팅/오버레이류
691
693
 
@@ -1208,6 +1210,7 @@ tableRef.current.scrollToRow(row); // 복원
1208
1210
  | 일반 액션 | `SButton` (`color`: `primary` / `secondary` / `neutral` / `danger`, `outline`, `size`: xs~md) |
1209
1211
  | **삭제 등 파괴적 액션** | **`SButton color="danger"`** 또는 **`color="danger" outline`** (아래 3-5-3) |
1210
1212
  | **아이콘 하나로 뜻이 통하는 부가 조작** | **`SGhostButton`** — 라벨이 없는 아이콘 전용 버튼 (아래 3-5-5) |
1213
+ | **부가 조작 여러 개를 ⋯ 메뉴로 접기** | **`SMeatballButton`** — ⋯ 아이콘 버튼 + `SDropdownButton` 과 같은 메뉴 (아래 3-5-5) |
1211
1214
  | 본문 속 이동 링크 | `STextLink` |
1212
1215
  | 메인 액션 + 부가 메뉴 | `SDropdownButton` |
1213
1216
  | **GNB system 패널의 한 줄 액션** | **`SSystemActionButton`** — 행 전체가 버튼인 표면형 (아래 3-5-7) |
@@ -1321,6 +1324,24 @@ tableRef.current.scrollToRow(row); // 복원
1321
1324
  ❌ <SGhostButton icon="download" ariaLabel="엑셀 다운로드" /> // 목록의 주 액션 → SButton
1322
1325
  ```
1323
1326
 
1327
+ ##### `SMeatballButton` — ⋯ 뒤에 메뉴를 접어 둘 때
1328
+
1329
+ 자리가 좁아 부가 조작을 하나하나 아이콘으로 늘어놓을 수 없을 때, **⋯ 하나로 접고 누르면 메뉴를 띄운다.** 버튼의 크기·색·hover·툴팁은 `SGhostButton` 과, 메뉴 모양은 `SDropdownButton` 과 같다.
1330
+
1331
+ - **메뉴는 컴포넌트가 띄운다.** `items` 를 넘기고 선택은 `onItemClick(value)` 로 받는다. 메뉴를 따로 만들어 붙이지 않고, 열림 상태(`aria-expanded`)도 직접 넣지 않는다.
1332
+ - **`SGhostButton icon="etc"` 로 직접 만들지 않는다.** '더 보기' 메뉴는 항상 `SMeatballButton` 이다.
1333
+ - **`intent` 는 `default` / `inverse` 뿐이다.** ⋯ 는 조작이 아니라 메뉴를 여는 것이므로 `action`·`danger` 색을 받지 않는다. 어두운 배경 위에서만 `inverse`.
1334
+ - 툴팁·`ariaLabel` 은 기본 문구가 들어 있어 **주지 않아도 된다.** 한 화면에 ⋯ 가 여러 개라 무엇의 메뉴인지 구분해야 할 때만 `tooltipText` 를 바꾼다(`ariaLabel` 이 따라간다).
1335
+ - 메인 액션에 변형 동작을 곁들이는 것이면 `SMeatballButton` 이 아니라 `SDropdownButton split`(§3-5-4)이다.
1336
+
1337
+ ```tsx
1338
+ ✅ <SMeatballButton size="xs" // 행 끝 부가 조작 묶음
1339
+ items={[{ value: 'copy', label: '복제', icon: 'copy' }, { value: 'remove', label: '삭제', icon: 'remove' }]}
1340
+ onItemClick={v => handleRowAction(row, v)} />
1341
+ ✅ <SMeatballButton intent="inverse" items={items} onItemClick={onAction} /> // 어두운 배경 위
1342
+ ❌ <SGhostButton icon="etc" ariaLabel="더 보기" onClick={openMyMenu} /> // 직접 조립 → SMeatballButton
1343
+ ```
1344
+
1324
1345
  #### 3-5-6. 그 밖
1325
1346
 
1326
1347
  - 라벨은 **동사(+목적어)** — `저장` · `적치 지시` · `송장 재출력`
@@ -1332,6 +1353,7 @@ tableRef.current.scrollToRow(row); // 복원
1332
1353
  - **왼쪽 끝**: 취소·닫기·목록 등 되돌리는 액션
1333
1354
  - **오른쪽 끝**: 저장·등록·수정·삭제 등 실행 액션
1334
1355
  - 체크박스·안내 문구 등 **부가 요소는 오른쪽 그룹 안, 실행 버튼 바로 왼쪽**에 둔다
1356
+ - **되돌리는 액션이 없어도 양끝 분리는 그대로다** — 오른쪽에 모으지 않는다. 그때는 **위계가 낮은 실행 액션이 왼쪽 끝, 가장 강한 실행 액션이 오른쪽 끝**이다. 뒤로가기를 헤더의 `onBack` 이 맡은 상세 페이지(§4-4)의 `삭제`(`danger outline`) · `수정`(`primary` 채움)이 이 경우다.
1335
1357
 
1336
1358
  ```tsx
1337
1359
  <div className="flex items-center justify-between">
@@ -1932,6 +1954,8 @@ const [selectedId, setSelectedId] = useState<string>();
1932
1954
  >
1933
1955
  > **아래 레시피는 §2-0 조합 문법으로 유도된 결과다.** 여기 없는 화면(대시보드·설정·마법사 등)을 만들 때는 임의로 짜지 말고 §2-0 의 층 구조·포함 규칙·블록 순서로 직접 유도한다.
1934
1956
  >
1957
+ > **레시피 코드의 `./AppShell` 은 §4-1 의 셸, `./api`·`./router` 는 앱의 데이터 계층·라우터 자리다.** 각 레시피는 Storybook `Recipes/…` 에서 실제로 띄워 볼 수 있고, 아래에 적힌 분기(칩 필터·섹션 나눔 등)의 변형 코드도 거기 있다.
1958
+ >
1935
1959
  > **핵심 원칙 — 표 형태의 정보는 `SKeyValueTable` 로 만든다.** 필터·등록/수정 폼·상세 정보가 모두 여기 해당한다.
1936
1960
  > `SField` 컨트롤을 `div` 로 직접 나열해 폼을 만들지 않는다.
1937
1961
  > (필터만 예외가 하나 있다 — 조건 후보가 많아 골라 붙이게 해야 하면 `SChipFilter` 다. §3-7-11)
@@ -1939,7 +1963,11 @@ const [selectedId, setSelectedId] = useState<string>();
1939
1963
  ### 4-1. 앱 셸 (모든 페이지 공통)
1940
1964
 
1941
1965
  ```tsx
1942
- import { SLayout, SGnb, SPage, SPageHeader, type SGnbMenuItem, type SPageHeaderProps } from 'sellmate-design-system-react';
1966
+ import type { ReactNode } from 'react';
1967
+ import {
1968
+ SLayout, SGnb, SPage, SPageHeader,
1969
+ type SGnbMenuItem, type SPageHeaderProps, type SPageContentHeight, type SPageContentWidth, type SPagePadding,
1970
+ } from 'sellmate-design-system-react';
1943
1971
 
1944
1972
  const MENU: SGnbMenuItem[] = [
1945
1973
  { label: '주문', value: 'orders', icon: 'bill' },
@@ -1951,12 +1979,15 @@ export default function AppShell({
1951
1979
  header,
1952
1980
  scrollEndSpacing,
1953
1981
  contentHeight,
1982
+ contentWidth,
1983
+ padding,
1954
1984
  }: {
1955
- children: React.ReactNode;
1985
+ children: ReactNode;
1956
1986
  header?: SPageHeaderProps;
1957
1987
  scrollEndSpacing?: boolean;
1958
1988
  contentHeight?: SPageContentHeight;
1959
1989
  contentWidth?: SPageContentWidth;
1990
+ padding?: SPagePadding;
1960
1991
  }) {
1961
1992
  return (
1962
1993
  <SLayout type="box" header="fix">
@@ -1965,7 +1996,8 @@ export default function AppShell({
1965
1996
  {/* 페이지 제목은 SPage 앞에 형제로 둔다 — SLayout 이 헤더부터 뒤를 한 열로 묶는다.
1966
1997
  header 는 페이지마다 달라 AppShell 이 그대로 받아 넘긴다 — 여기서 만들지 않는다 */}
1967
1998
  {header && <SPageHeader {...header} />}
1968
- {/* 콘텐츠 패딩은 SPage 가 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다 */}
1999
+ {/* 콘텐츠 패딩은 SPage 가 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다.
2000
+ 가장자리까지 차는 블록이 맨 위에 서는 페이지만 padding="none" 으로 끈다 (§4-2 칩 필터) */}
1969
2001
  {/* 높이 모드는 페이지가 정한다 — 대부분 contentHeight="fill" 이다 (§2-2) */}
1970
2002
  {/* 스크롤 끝 여백도 SPage 가 넣는다. 페이지가 실제로 스크롤되는 화면에서만 켠다 */}
1971
2003
  {/* 가로 폭 모드도 페이지가 정한다 — 넓은 창에서 본문을 늘릴지 최소 폭으로 세울지 */}
@@ -1974,6 +2006,7 @@ export default function AppShell({
1974
2006
  scrollEndSpacing={scrollEndSpacing}
1975
2007
  contentHeight={contentHeight}
1976
2008
  contentWidth={contentWidth}
2009
+ padding={padding}
1977
2010
  >
1978
2011
  {children}
1979
2012
  </SPage>
@@ -2179,7 +2212,7 @@ import { SModalOutlet } from 'sellmate-design-system-react';
2179
2212
 
2180
2213
  **뒤의 둘은 한 블록이라 붙여 쓴다** — 툴바는 자기 아래 표의 건수와 액션을 말하므로 사이를 벌리지 않는다. 묶는 방법과 맞닿는 변 처리는 §2-0 에 있다.
2181
2214
 
2182
- 필터 자리는 `SChipFilter` 로 바꿔 놓을 수 있다 — 조건 후보가 많아 쓸 것만 골라 붙이게 하는 화면이면 그쪽이다 (§3-7-11). 나머지 골격은 같다.
2215
+ 필터 자리는 `SChipFilter` 로 바꿔 놓을 수 있다 — 조건 후보가 많아 쓸 것만 골라 붙이게 하는 화면이면 그쪽이다 (§3-7-11). 나머지 골격은 같지만 여백이 다르다 — **칩 필터 바는 페이지 가장자리까지 찬다.** `SPage` 에 `padding="none"` 을 주고(§4-1 의 셸이 넘긴다), 바에 `className="border-b"` 로 아래를 긋고(흰 바탕이라 흰 페이지 위에서 경계가 없다 — 선 색은 바가 토큰으로 들고 있어 두께만 준다), 그 아래 툴바+표 블록이 `p-[var(--cmp-pageBody-padding-default)]` 로 같은 토큰의 여백을 잡는다. 코드는 Storybook `Recipes/목록 페이지` 의 "칩 필터" 에 있다.
2183
2216
 
2184
2217
  액션 버튼의 위치가 핵심이다:
2185
2218
 
@@ -2191,44 +2224,120 @@ import { SModalOutlet } from 'sellmate-design-system-react';
2191
2224
  - `fill` 에서는 페이지가 스크롤하지 않으므로 **`scrollEndSpacing` 은 무시된다** — 따로 끄지 않는다 (§2-2).
2192
2225
  - **정렬 가능한 컬럼은 `sortable` 로 준다.** 정렬 상태(`sort`)는 이 페이지가 들고 `onSortChange` 로 받는다 — 조회 조건이라 URL 에 실려야 한다 (§3-4).
2193
2226
 
2227
+ <!-- recipe:src/recipes/ProductListPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
2194
2228
  ```tsx
2229
+ import { useState } from 'react';
2195
2230
  import {
2196
- SButton, SKeyValueTable, STableBar, STable, STag,
2197
- type STableColumn, type SRow, type SKeyValueField,
2231
+ SButton,
2232
+ SKeyValueTable,
2233
+ STable,
2234
+ STableBar,
2235
+ STag,
2236
+ type SDateRangeValue,
2237
+ type SKeyValueField,
2238
+ type SRow,
2239
+ type STableColumn,
2240
+ type STableSort,
2198
2241
  } from 'sellmate-design-system-react';
2242
+ import AppShell from './AppShell';
2243
+ import { STATUS_OPTIONS, removeProducts, useProductList, type ProductQuery } from './api';
2244
+ import { useNavigate } from './router';
2199
2245
 
2200
2246
  /** 필터도 표다 — SKeyValueTable 로 만든다 (서비스 전용 전역 필터가 따로 있는 경우 제외) */
2201
2247
  const filterFields: SKeyValueField[][] = [
2202
2248
  [
2203
- { name: 'status', label: '상태', type: 'select',
2204
- options: { options: STATUS_OPTIONS, valueAsPrimitive: true } },
2205
- { name: 'keyword', label: '검색어', type: 'input',
2206
- options: { placeholder: '상품명 / 상품코드' } },
2249
+ // 조회 조건으로 쓰는 셀렉트·날짜 피커는 clearable — 걸어 둔 조건을 풀 수 있어야 한다 (§3-7-4)
2250
+ {
2251
+ name: 'status',
2252
+ label: '상태',
2253
+ type: 'select',
2254
+ options: { options: STATUS_OPTIONS, valueAsPrimitive: true, clearable: true },
2255
+ },
2256
+ {
2257
+ name: 'keyword',
2258
+ label: '검색어',
2259
+ type: 'input',
2260
+ options: { placeholder: '상품명 / 상품코드' },
2261
+ },
2207
2262
  ],
2208
2263
  [
2209
- { name: 'period', label: '등록일', type: 'date-range-picker', tdColSpan: 3 },
2264
+ {
2265
+ name: 'period',
2266
+ label: '등록일',
2267
+ type: 'date-range-picker',
2268
+ tdColSpan: 3,
2269
+ options: { clearable: true },
2270
+ },
2210
2271
  ],
2211
2272
  ];
2212
2273
 
2274
+ // 정렬·폭은 값의 성격에서 나온다 (§3-4). 모든 컬럼에 폭을 px 로 명시한다
2213
2275
  const columns: STableColumn[] = [
2214
- // 숫자 컬럼은 전부 align: 'right' — §3-4
2215
- { name: 'id', label: 'ID', field: 'id', width: '80px', align: 'right' },
2216
- { name: 'name', label: '상품명', field: 'name' },
2217
- { name: 'stock', label: '재고', field: 'stock', width: '90px', align: 'right',
2218
- format: (v: number) => `${Number(v).toLocaleString()}개` },
2219
- { name: 'price', label: '판매가', field: 'price', width: '120px', align: 'right',
2220
- format: (v: number) => `${Number(v).toLocaleString()}원` },
2221
- // 상태 컬럼은 STag size="sm" — SBadge 색 점을 기본으로 쓰지 않는다
2222
- { name: 'status', label: '상태', field: 'status', width: '100px', align: 'center',
2276
+ // ID·코드는 크기를 비교하는 숫자가 아니라 식별자다 → center. right 는 양을 나타내는 값에만
2277
+ { name: 'id', label: 'ID', field: 'id', width: 80, align: 'center' },
2278
+ // 자유 입력 텍스트 → 기준 폭을 주고, 표에 resizable 을 켜서 사용자가 늘릴 수 있게 한다
2279
+ { name: 'name', label: '상품명', field: 'name', width: 240 },
2280
+ // 값의 크기를 비교하는 컬럼만 sortable — 정렬 상태는 페이지가 들고 있다
2281
+ {
2282
+ name: 'stock',
2283
+ label: '재고',
2284
+ field: 'stock',
2285
+ width: 100,
2286
+ align: 'right',
2287
+ sortable: true,
2288
+ format: (v: unknown) => `${Number(v).toLocaleString()}개`,
2289
+ },
2290
+ {
2291
+ name: 'price',
2292
+ label: '판매가',
2293
+ field: 'price',
2294
+ width: 120,
2295
+ align: 'right',
2296
+ sortable: true,
2297
+ format: (v: unknown) => `${Number(v).toLocaleString()}원`,
2298
+ },
2299
+ // 상태 컬럼은 STag size="sm" — SBadge 색 점을 기본으로 쓰지 않는다.
2300
+ // 컨트롤이 들어가는 컬럼은 contentType: 'control', resizable 표에서는 minWidth 도 함께 (§3-4)
2301
+ {
2302
+ name: 'status',
2303
+ label: '상태',
2304
+ field: 'status',
2305
+ width: 100,
2306
+ minWidth: 100,
2307
+ align: 'center',
2308
+ contentType: 'control',
2223
2309
  render: (row: SRow) =>
2224
- row.status === 'selling'
2225
- ? <STag size="sm" color="green" label="판매중" />
2226
- : <STag size="sm" color="grey" label="판매중지" /> },
2310
+ row.status === 'selling' ? (
2311
+ <STag size="sm" color="green" label="판매중" />
2312
+ ) : (
2313
+ <STag size="sm" color="grey" label="판매중지" />
2314
+ ),
2315
+ },
2227
2316
  ];
2228
2317
 
2229
2318
  export default function ProductListPage() {
2319
+ const navigate = useNavigate();
2320
+ // 편집 중인 필터 값과, 검색을 눌러 실제로 걸린 조회 조건은 따로 든다.
2321
+ // 조회 조건(필터·정렬·페이지)은 실제 앱에서는 URL 쿼리에 싣는다 — 새로고침·공유에 살아남아야 한다
2230
2322
  const [filters, setFilters] = useState<Record<string, unknown>>({});
2323
+ const [query, setQuery] = useState<ProductQuery>({ page: 1, rowsPerPage: 20 });
2231
2324
  const [selected, setSelected] = useState<SRow[]>([]);
2325
+ const { rows, total, lastPage, loading } = useProductList(query);
2326
+
2327
+ const search = () => {
2328
+ setQuery(q => ({
2329
+ ...q,
2330
+ status: (filters.status as string | null) ?? undefined,
2331
+ keywords: filters.keyword ? [String(filters.keyword)] : undefined,
2332
+ period: (filters.period as SDateRangeValue) ?? undefined,
2333
+ page: 1,
2334
+ }));
2335
+ };
2336
+
2337
+ const removeSelected = async () => {
2338
+ await removeProducts(selected.map(row => Number(row.id)));
2339
+ setSelected([]);
2340
+ };
2232
2341
 
2233
2342
  return (
2234
2343
  <AppShell
@@ -2236,7 +2345,15 @@ export default function ProductListPage() {
2236
2345
  variant: 'bar',
2237
2346
  title: '상품 목록',
2238
2347
  // 이 페이지의 주요 액션이 아니라 부가 액션 — slot 은 sm 버튼으로만 채운다
2239
- slot: <SButton size="sm" color="neutral" outline label="이용 가이드" onClick={openGuide} />,
2348
+ slot: (
2349
+ <SButton
2350
+ size="sm"
2351
+ color="neutral"
2352
+ outline
2353
+ label="이용 가이드"
2354
+ onClick={() => navigate('/guide/products')}
2355
+ />
2356
+ ),
2240
2357
  }}
2241
2358
  contentHeight="fill" // 표가 남은 높이를 채우고 페이지네이션이 하단에 고정된다
2242
2359
  >
@@ -2250,10 +2367,11 @@ export default function ProductListPage() {
2250
2367
  fieldWidth="fill"
2251
2368
  search
2252
2369
  onChange={({ values }) => setFilters(values)}
2253
- onSearch={fetchList}
2370
+ onSearch={search}
2254
2371
  />
2255
2372
 
2256
- {/* 툴바+표 = 한 블록. 사이를 벌리지 않는다 (§2-0) */}
2373
+ {/* 툴바+표 = 한 블록. 사이를 벌리지 않는다 (§2-0).
2374
+ 이 div 도 높이 사슬의 한 칸이라 min-h-0 flex-1 이 필요하다 */}
2257
2375
  <div className="flex min-h-0 flex-1 flex-col">
2258
2376
  {/* 툴바 — 좌: 건수 + (구분선 자동) + 선택 액션 / 우: 주요 액션
2259
2377
  맞닿는 가로선은 표가 그리므로 아래 테두리는 지운다 */}
@@ -2263,25 +2381,37 @@ export default function ProductListPage() {
2263
2381
  selected={selected.length}
2264
2382
  actions={
2265
2383
  /* 선택 항목 단위 파괴 액션 → danger outline (§3-5-3) */
2266
- <SButton size="sm" color="danger" outline label="선택 삭제"
2267
- disabled={!selected.length} onClick={removeSelected} />
2384
+ <SButton
2385
+ size="sm"
2386
+ color="danger"
2387
+ outline
2388
+ label="선택 삭제"
2389
+ disabled={!selected.length}
2390
+ onClick={removeSelected}
2391
+ />
2268
2392
  }
2269
2393
  rightActions={
2270
2394
  /* 이 페이지의 유일한 primary 채움 (§3-5-1) */
2271
- <SButton size="sm" label="상품 등록" onClick={goCreate} />
2395
+ <SButton size="sm" label="상품 등록" onClick={() => navigate('/products/new')} />
2272
2396
  }
2273
2397
  />
2274
2398
 
2275
- {/* 남은 높이를 채우고 본문만 스크롤한다 — 페이지네이션 바는 표 안에서 하단 고정 */}
2399
+ {/* 남은 높이를 채우고 본문만 스크롤한다 — 페이지네이션 바는 표 안에서 하단 고정.
2400
+ dense 는 시작 밀도이자 밀도 토글의 스위치 — onDenseChange 값을 되돌려 넣지 않는다 (§4-2) */}
2276
2401
  <STable
2277
2402
  className="min-h-0 flex-1"
2278
2403
  columns={columns}
2279
2404
  rows={rows}
2280
2405
  rowKey="id"
2406
+ dense
2407
+ resizable
2281
2408
  selectable
2282
2409
  selected={selected}
2283
2410
  onSelectedChange={setSelected}
2284
- pagination={{ currentPage, lastPage }}
2411
+ sort={query.sort}
2412
+ onSortChange={(sort: STableSort | null) => setQuery(q => ({ ...q, sort, page: 1 }))}
2413
+ pagination={{ page: query.page, rowsPerPage: query.rowsPerPage, lastPage }}
2414
+ onPageChange={page => setQuery(q => ({ ...q, page }))}
2285
2415
  loading={loading}
2286
2416
  />
2287
2417
  </div>
@@ -2290,6 +2420,7 @@ export default function ProductListPage() {
2290
2420
  );
2291
2421
  }
2292
2422
  ```
2423
+ <!-- /recipe -->
2293
2424
 
2294
2425
  #### 한 화면에 더 많은 행을 — `dense` 와 밀도 토글
2295
2426
 
@@ -2299,7 +2430,7 @@ export default function ProductListPage() {
2299
2430
 
2300
2431
  ```tsx
2301
2432
  // 좁게 시작하고, 사용자가 바꾸는 밀도는 표가 알아서 들고 간다
2302
- <STable dense showRowsPerPageSelect pagination={{ currentPage, lastPage }} />;
2433
+ <STable dense showRowsPerPageSelect pagination={{ page, rowsPerPage, lastPage }} />;
2303
2434
 
2304
2435
  // 사용자가 고른 밀도를 다음 방문에도 남기려는 화면만 받아서 저장한다.
2305
2436
  // 저장한 값은 시작 밀도로만 돌려준다 — 되돌려 넣지 않는다
@@ -2309,7 +2440,7 @@ const [initialDense] = useState(() => loadPref('list.dense', true));
2309
2440
  dense={initialDense}
2310
2441
  onDenseChange={next => savePref('list.dense', next)}
2311
2442
  showRowsPerPageSelect
2312
- pagination={{ currentPage, lastPage }}
2443
+ pagination={{ page, rowsPerPage, lastPage }}
2313
2444
  />;
2314
2445
  ```
2315
2446
 
@@ -2374,61 +2505,113 @@ const [initialDense] = useState(() => loadPref('list.dense', true));
2374
2505
 
2375
2506
  **병합으로 찬 자리는 배열 순서가 아니라 실제 열 위치로 판정한다.** 그러니 아래 행의 필드를 병합된 열에 맞추려고 자리 채우기용 빈 필드를 끼우지 않는다 — 앞 필드에 `tdColSpan` 이나 `hideTh` 가 있어도 남은 필드가 알아서 빈 열부터 놓인다.
2376
2507
 
2508
+ <!-- recipe:src/recipes/ProductCreatePage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
2377
2509
  ```tsx
2510
+ import { useState } from 'react';
2378
2511
  import {
2379
- SForm, SKeyValueTable, SButton, SCheckbox,
2380
- type SFormHandle, type SKeyValueField, type Rule,
2512
+ SButton,
2513
+ SCheckbox,
2514
+ SForm,
2515
+ SKeyValueTable,
2516
+ type Rule,
2517
+ type SKeyValueField,
2381
2518
  } from 'sellmate-design-system-react';
2519
+ import AppShell from './AppShell';
2520
+ import { CATEGORY_OPTIONS, saveProduct } from './api';
2521
+ import { useNavigate } from './router';
2382
2522
 
2383
- const required = (msg: string): Rule => v =>
2384
- v != null && String(v).trim() !== '' ? true : msg;
2523
+ const required =
2524
+ (msg: string): Rule =>
2525
+ v =>
2526
+ v != null && String(v).trim() !== '' ? true : msg;
2385
2527
 
2528
+ /** 필드를 div 로 나열하지 않는다 — SKeyValueTable 의 행으로 구성한다 */
2386
2529
  const fields: SKeyValueField[][] = [
2387
2530
  [
2388
- { name: 'name', label: '상품명', required: true, type: 'input',
2389
- options: { placeholder: '상품명 입력', rules: [required('상품명을 입력해 주세요.')] } },
2531
+ {
2532
+ name: 'name',
2533
+ label: '상품명',
2534
+ required: true,
2535
+ type: 'input',
2536
+ options: { placeholder: '상품명 입력', rules: [required('상품명을 입력해 주세요.')] },
2537
+ },
2390
2538
  { name: 'code', label: '상품코드', type: 'input', options: { placeholder: '자동 생성' } },
2391
2539
  ],
2392
2540
  [
2393
- { name: 'category', label: '카테고리', required: true, type: 'select',
2394
- options: { options: CATEGORY_OPTIONS, valueAsPrimitive: true,
2395
- rules: [required('카테고리를 선택해 주세요.')] } },
2541
+ {
2542
+ name: 'category',
2543
+ label: '카테고리',
2544
+ required: true,
2545
+ type: 'select',
2546
+ options: {
2547
+ options: CATEGORY_OPTIONS,
2548
+ valueAsPrimitive: true,
2549
+ rules: [required('카테고리를 선택해 주세요.')],
2550
+ },
2551
+ },
2396
2552
  { name: 'price', label: '판매가', type: 'number-input' },
2397
2553
  ],
2398
2554
  [
2399
- { name: 'memo', label: '메모', type: 'textarea', tdColSpan: 3,
2400
- helpText: ['내부 관리용 메모입니다.'] },
2555
+ // 가장 긴 행(필드 2개 = 4칸)에 맞춰 마지막 필드가 남는 칸을 채운다
2556
+ {
2557
+ name: 'memo',
2558
+ label: '메모',
2559
+ type: 'textarea',
2560
+ tdColSpan: 3,
2561
+ helpText: ['내부 관리용 메모입니다.'],
2562
+ },
2401
2563
  ],
2402
2564
  ];
2403
2565
 
2404
2566
  export default function ProductCreatePage() {
2405
- const formRef = useRef<SFormHandle>(null);
2567
+ const navigate = useNavigate();
2406
2568
  const [values, setValues] = useState<Record<string, unknown>>({});
2569
+ const [keepOpen, setKeepOpen] = useState(false);
2570
+
2571
+ // SForm 이 하위 컨트롤의 rules 를 모아 submit 때 일괄 검증한다 — 통과했을 때만 불린다
2572
+ const save = async () => {
2573
+ await saveProduct(values);
2574
+ if (keepOpen) setValues({});
2575
+ else navigate('/products');
2576
+ };
2407
2577
 
2408
2578
  return (
2409
- <AppShell header={{ variant: 'bar', title: '상품 등록', onBack: goBack }}>
2410
- <div className="flex flex-col gap-sd-12">
2411
- <SForm ref={formRef} formClassName="flex flex-col gap-sd-12" onSubmit={save}>
2412
- <SKeyValueTable
2413
- fields={fields}
2414
- values={values}
2415
- onChange={({ values }) => setValues(values)}
2416
- />
2579
+ <AppShell
2580
+ header={{ variant: 'bar', title: '상품 등록', onBack: () => navigate(-1) }}
2581
+ contentHeight="fill" // 폼 페이지도 fill 이 기본 — 폼이 길어 실제로 스크롤될 때만 auto + scrollEndSpacing
2582
+ >
2583
+ <SForm formClassName="flex flex-col gap-sd-12" onSubmit={save}>
2584
+ <SKeyValueTable
2585
+ fields={fields}
2586
+ values={values}
2587
+ onChange={({ values }) => setValues(values)}
2588
+ />
2417
2589
 
2418
- {/* 하단 버튼은 양끝으로 벌린다. 부가 요소(체크박스 등)는 저장 바로 왼쪽 */}
2419
- <div className="flex items-center justify-between">
2420
- <SButton type="button" color="neutral" outline label="취소" onClick={goBack} />
2421
- <div className="flex items-center gap-sd-8">
2422
- <SCheckbox label="계속 등록하기" value={keepOpen} onValueChange={v => setKeepOpen(v as boolean)} />
2423
- <SButton type="submit" label="저장" />
2424
- </div>
2590
+ {/* 버튼 순서는 모든 화면에서 같다 — 취소는 왼쪽 끝, 저장은 오른쪽 끝.
2591
+ 부가 요소(체크박스 등)는 저장 바로 왼쪽 */}
2592
+ <div className="flex items-center justify-between">
2593
+ <SButton
2594
+ type="button"
2595
+ color="neutral"
2596
+ outline
2597
+ label="취소"
2598
+ onClick={() => navigate(-1)}
2599
+ />
2600
+ <div className="flex items-center gap-sd-8">
2601
+ <SCheckbox
2602
+ label="계속 등록하기"
2603
+ value={keepOpen}
2604
+ onValueChange={v => setKeepOpen(v as boolean)}
2605
+ />
2606
+ <SButton type="submit" label="저장" />
2425
2607
  </div>
2426
- </SForm>
2427
- </div>
2608
+ </div>
2609
+ </SForm>
2428
2610
  </AppShell>
2429
2611
  );
2430
2612
  }
2431
2613
  ```
2614
+ <!-- /recipe -->
2432
2615
 
2433
2616
  ### 4-4. 상세(조회) 페이지
2434
2617
 
@@ -2436,7 +2619,7 @@ export default function ProductCreatePage() {
2436
2619
 
2437
2620
  - 조회 값은 `type: 'text'` 행으로 표시한다. **상태·분류 태그도 별도 영역이 아니라 표의 한 행**으로 넣는다 (`render` 에 `STag`).
2438
2621
  - 행이 많아지면 **유형별로 섹션을 나누고, 각 섹션을 `SSectionHeaderCard` 로 감싼다.**
2439
- 섹션 제목은 `title` prop 으로, 바디 여백은 `padding` prop 으로 준다.
2622
+ 섹션 제목은 `title` prop 으로, 바디 여백은 `padding` prop 으로 준다. **카드 안이 `SKeyValueTable` 하나뿐이면 `padding="none"` 으로 가장자리까지 채우고 표는 `bordered={false} radius="useTop"`** 으로 자기 테두리를 끈다 (§2-2 "카드 가장자리까지 채우는 표").
2440
2623
  - **한 화면에 행이 많아 훑어보기가 목적이면 `SKeyValueTable` 에 `dense` 를 켠다** — 셀 상하 여백과
2441
2624
  최소 높이가 함께 내려가 행이 좁아진다. 다만 **읽기 전용(`text`) 행에서만 실제로 좁아진다**:
2442
2625
  폼 필드가 든 행은 필드 자체의 높이가 행 높이를 정하므로 거의 변하지 않는다. 그래서 필터(§4-2)나
@@ -2445,23 +2628,19 @@ export default function ProductCreatePage() {
2445
2628
  - **상세 페이지도 `contentHeight="fill"` 이 기본이다**(§2-2). 섹션이 많아 페이지가 실제로 스크롤되는 화면에서만 `auto` + `scrollEndSpacing` 을 켠다.
2446
2629
  - **섹션마다 독립 인라인 폼이 있는 형태**도 상세 페이지의 변형이다. 섹션 안에서 바로 수정·저장하게 하는 화면인데, 이때 버튼 강조는 **섹션 단위가 아니라 페이지 단위로 판단한다** — §3-5-1 의 "`secondary` 연속 배치 금지"는 섹션이 다르면 적용되지 않는다. 그 섹션 안에서 닫히는 저장·취소는 `SSectionHeaderCard` 의 `button`·`footerLeft` 로 넘긴다 (§3-7-8). 아래 예처럼 **페이지 전체를 확정하는 버튼은 카드 밖 하단 줄**에 둔다 — 둘을 섞지 않는다.
2447
2630
 
2631
+ <!-- recipe:src/recipes/ProductDetailPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
2448
2632
  ```tsx
2449
2633
  import {
2450
- SSectionHeaderCard, SKeyValueTable, SButton, STag, type SKeyValueField,
2634
+ SButton,
2635
+ SKeyValueTable,
2636
+ SModal,
2637
+ SSectionHeaderCard,
2638
+ STag,
2639
+ type SKeyValueField,
2451
2640
  } from 'sellmate-design-system-react';
2452
-
2453
- const basicFields: SKeyValueField[][] = [
2454
- [
2455
- { name: 'code', label: '상품코드', type: 'text' },
2456
- { name: 'createdAt', label: '등록일', type: 'text' },
2457
- ],
2458
- [
2459
- { name: 'category', label: '카테고리', type: 'text' },
2460
- // 상태 태그도 표의 한 행으로 표현한다
2461
- { name: 'status', label: '상태',
2462
- render: <STag size="sm" color="green" label="판매중" /> },
2463
- ],
2464
- ];
2641
+ import AppShell from './AppShell';
2642
+ import { removeProducts, useProduct } from './api';
2643
+ import { useNavigate, useParams } from './router';
2465
2644
 
2466
2645
  const priceFields: SKeyValueField[][] = [
2467
2646
  [
@@ -2471,41 +2650,96 @@ const priceFields: SKeyValueField[][] = [
2471
2650
  ];
2472
2651
 
2473
2652
  export default function ProductDetailPage() {
2653
+ const navigate = useNavigate();
2654
+ const { id } = useParams();
2655
+ const product = useProduct(Number(id));
2656
+
2657
+ // 되돌릴 수 없는 액션은 실행 여부만 확정받는다 — 직접 만든 confirm 대신 SModal.confirm (§3-3-1)
2658
+ const confirmDelete = () =>
2659
+ SModal.confirm({
2660
+ type: 'negative',
2661
+ modalTitle: '상품을 삭제할까요?',
2662
+ topMessage: ['삭제된 상품은 복구할 수 없습니다.'],
2663
+ mainButtonLabel: '삭제',
2664
+ }).onOk(async () => {
2665
+ await removeProducts([product.id]);
2666
+ navigate('/products');
2667
+ });
2668
+
2669
+ // 조회 값은 type: 'text' 행으로 보인다.
2670
+ // 상태 태그도 별도 영역이 아니라 표의 한 행이다 — 값에 따라 달라지므로 컴포넌트 안에서 만든다
2671
+ const basicFields: SKeyValueField[][] = [
2672
+ [
2673
+ { name: 'code', label: '상품코드', type: 'text' },
2674
+ { name: 'createdAt', label: '등록일', type: 'text' },
2675
+ ],
2676
+ [
2677
+ { name: 'category', label: '카테고리', type: 'text' },
2678
+ {
2679
+ name: 'status',
2680
+ label: '상태',
2681
+ render:
2682
+ product.status === 'selling' ? (
2683
+ <STag size="sm" color="green" label="판매중" />
2684
+ ) : (
2685
+ <STag size="sm" color="grey" label="판매중지" />
2686
+ ),
2687
+ },
2688
+ ],
2689
+ ];
2690
+
2474
2691
  return (
2475
2692
  // 목록에서 들어온 상세 페이지 — onBack 으로 뒤로가기를 준다
2476
- <AppShell header={{ variant: 'bar', title: '클래식 셔츠', onBack: goList }}>
2693
+ <AppShell
2694
+ header={{ variant: 'bar', title: product.name, onBack: () => navigate('/products') }}
2695
+ contentHeight="fill" // 섹션이 많아 실제로 스크롤될 때만 auto + scrollEndSpacing
2696
+ >
2477
2697
  <div className="flex flex-col gap-sd-12">
2478
- <SSectionHeaderCard title="기본 정보" marker thickness="accent">
2479
- <SKeyValueTable fields={basicFields} values={product} />
2698
+ {/* 행이 많아지면 유형별로 섹션을 나눈다. 카드가 둘 이상이라 marker 를 켠다 —
2699
+ 한 페이지 안의 카드 강조(marker·thickness)는 전부 같게 간다 (§4-5).
2700
+ 카드 안이 표 하나뿐이면 padding="none" 으로 가장자리까지 채우고, 표는 bordered={false} 로
2701
+ 자기 테두리를 끄고 radius="useTop" 으로 위 모서리를 죽인다 — 카드가 테두리·아래 라운드를 그린다 (§2-2) */}
2702
+ <SSectionHeaderCard title="기본 정보" marker padding="none">
2703
+ <SKeyValueTable fields={basicFields} values={product} bordered={false} radius="useTop" />
2480
2704
  </SSectionHeaderCard>
2481
2705
 
2482
- {/* 헤더 우측에 액션이 필요하면 slot 을 쓴다 */}
2706
+ {/* 헤더 우측에 액션이 필요하면 slot 을 쓴다 — 이동 액션이라 outline (§3-5-1) */}
2483
2707
  <SSectionHeaderCard
2484
2708
  title="가격 정보"
2485
2709
  marker
2486
2710
  helpText={['부가세 포함 금액입니다.']}
2487
- slot={<SButton size="sm" color="secondary" label="이력" onClick={openHistory} />}
2711
+ padding="none"
2712
+ slot={
2713
+ <SButton
2714
+ size="sm"
2715
+ color="neutral"
2716
+ outline
2717
+ label="이력"
2718
+ onClick={() => navigate(`/products/${id}/price-history`)}
2719
+ />
2720
+ }
2488
2721
  >
2489
- <SKeyValueTable fields={priceFields} values={product} />
2722
+ <SKeyValueTable fields={priceFields} values={product} bordered={false} radius="useTop" />
2490
2723
  </SSectionHeaderCard>
2491
2724
 
2492
- {/* 액션은 하단 — 목록(되돌리기)은 왼쪽 끝, 실행 액션은 오른쪽 끝 */}
2725
+ {/* 액션은 하단 — 그룹을 모으지 않고 양끝으로 벌린다 (§3-5).
2726
+ 목록으로 돌아가기는 헤더의 onBack 이 이미 하므로 되돌리는 버튼을 따로 두지 않는다.
2727
+ 되돌리는 액션이 없어도 양끝 분리는 그대로다 — 위계가 낮은 삭제(danger outline)가 왼쪽 끝,
2728
+ 가장 강한 실행 액션인 수정이 오른쪽 끝 */}
2493
2729
  <div className="flex items-center justify-between">
2494
- <SButton color="neutral" outline label="목록" onClick={goList} />
2495
- <div className="flex items-center gap-sd-8">
2496
- <SButton color="danger" outline label="삭제" onClick={confirmDelete} />
2497
- <SButton label="수정" onClick={goEdit} />
2498
- </div>
2730
+ <SButton color="danger" outline label="삭제" onClick={confirmDelete} />
2731
+ <SButton label="수정" onClick={() => navigate(`/products/${id}/edit`)} />
2499
2732
  </div>
2500
2733
  </div>
2501
2734
  </AppShell>
2502
2735
  );
2503
2736
  }
2504
2737
  ```
2738
+ <!-- /recipe -->
2505
2739
 
2506
2740
  ### 4-5. 섹션 카드 — SSectionHeaderCard
2507
2741
 
2508
- 폼 페이지에서도 입력 항목이 많으면 유형별로 `SSectionHeaderCard` 로 나눈다. 주요 옵션:
2742
+ 폼 페이지에서도 입력 항목이 많으면 유형별로 `SSectionHeaderCard` 로 나눈다. **카드 안이 `SKeyValueTable` 하나뿐이면 `padding="none"` 으로 가장자리까지 채우고, 표는 `bordered={false} radius="useTop"` 으로 자기 테두리를 끈다** (§2-2 "카드 가장자리까지 채우는 표"). 코드는 Storybook `Recipes/폼 페이지` 의 "섹션 나눔" 에 있다. 주요 옵션:
2509
2743
 
2510
2744
  | Prop (Header) | 용도 |
2511
2745
  | --- | --- |
@@ -2548,24 +2782,48 @@ export default function ProductDetailPage() {
2548
2782
 
2549
2783
  로그인 전이라 앱 셸이 아직 없다. `SLayout`·`SGnb`·`SPage` 가 없고, 회색 바탕 위에 카드 하나만 선다. (앱 셸 없이 서는 화면은 이것과 팝업 라우트(§4-7) 둘뿐이다.)
2550
2784
 
2785
+ <!-- recipe:src/recipes/LoginPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
2551
2786
  ```tsx
2552
- <div className="flex h-screen items-stretch justify-center bg-(--sys-color-bg-neutralLight) p-sd-24">
2553
- <SLoginCard
2554
- product="AI assistant"
2555
- title="셀메이트 AI 어시스턴트와 함께 자료 찾는 시간은 줄이고, 업무는 빠르게!"
2556
- description="(사내 답변은 사내 문서를 기반으로 답변합니다.)"
2557
- onLogin={signIn}
2558
- footerNote="계정이 없다면 담당자에게 문의를 요청해 주세요."
2559
- bottomAction={
2560
- <STextLink label="관리자 로그인" icon="settingOutline" rightArrow="chevron" onClick={goAdmin} />
2561
- }
2562
- />
2563
- </div>
2787
+ import { SLoginCard, STextLink } from 'sellmate-design-system-react';
2788
+ import { useNavigate } from './router';
2789
+
2790
+ /**
2791
+ * 로그인 전이라 앱 셸이 없다 — AppShell 을 쓰지 않고 회색 바탕 위에 카드 하나만 선다.
2792
+ * 카드는 화면을 잡지 않으므로 바깥 한 겹은 앱이 만든다. 그 한 겹이 하는 일은 셋뿐이다:
2793
+ * 바탕·여백 / items-stretch 로 카드가 높이를 채우게 / justify-center 로 가로 가운데
2794
+ */
2795
+ export default function LoginPage() {
2796
+ const navigate = useNavigate();
2797
+
2798
+ return (
2799
+ // 여백은 카드가 최소 폭을 지킬 수 있는 값으로 둔다 — 카드 폭은 className 으로 박지 않는다
2800
+ <div className="flex h-screen items-stretch justify-center bg-bg-neutralLight p-sd-24">
2801
+ <SLoginCard
2802
+ product="AI assistant"
2803
+ title="셀메이트 AI 어시스턴트와 함께 자료 찾는 시간은 줄이고, 업무는 빠르게!"
2804
+ description="(사내 답변은 사내 문서를 기반으로 답변합니다.)"
2805
+ // 로그인 버튼은 카드가 그린다 — 동작만 넘긴다
2806
+ onLogin={() => navigate('/auth/sso')}
2807
+ footerNote="계정이 없다면 담당자에게 문의를 요청해 주세요."
2808
+ // 로그인과 성격이 다른 보조 진입점만 — STextLink 하나. 버튼을 세우지 않는다
2809
+ bottomAction={
2810
+ <STextLink
2811
+ label="관리자 로그인"
2812
+ icon="settingOutline"
2813
+ rightArrow="chevron"
2814
+ onClick={() => navigate('/admin/login')}
2815
+ />
2816
+ }
2817
+ />
2818
+ </div>
2819
+ );
2820
+ }
2564
2821
  ```
2822
+ <!-- /recipe -->
2565
2823
 
2566
2824
  **바깥 한 겹은 앱이 만든다.** 카드는 화면을 잡지 않는다 — 덕분에 로그인 화면 밖(세션 만료 재인증 등)에서도 같은 카드를 그대로 쓴다. 그 한 겹은 위 세 가지만 하면 되고, 그 이상은 하지 않는다.
2567
2825
 
2568
- - 바탕은 `bg-(--sys-color-bg-neutralLight)`, 여백은 `p-sd-24`. **여백은 카드가 최소 폭(368px)을 지킬 수 있는 값으로 둔다** — 좌우 합이 창 폭에서 368px 을 남기지 못하면 카드가 넘친다
2826
+ - 바탕은 `bg-bg-neutralLight`, 여백은 `p-sd-24`. **여백은 카드가 최소 폭(368px)을 지킬 수 있는 값으로 둔다** — 좌우 합이 창 폭에서 368px 을 남기지 못하면 카드가 넘친다
2569
2827
  - `items-stretch` — 카드가 남은 높이를 채운다. `items-center` 로 두면 카드가 내용 높이로 줄어든다
2570
2828
  - `justify-center` — 카드를 가로 가운데에
2571
2829
 
@@ -2592,9 +2850,23 @@ export default function ProductDetailPage() {
2592
2850
 
2593
2851
  앱 셸 없이 서는 또 하나의 화면이다. 별도 브라우저 창으로 열리는 전용 라우트이므로 `SLayout`·`SGnb`·`SPage` 가 없고, 창을 통째로 `SPopup` 하나가 채운다. 무엇을 팝업으로 열지는 §3-3-1·§3-3-3 에서 고르고, 여기서는 그 라우트의 골격만 다룬다.
2594
2852
 
2853
+ <!-- recipe:src/recipes/TransferOrderPopupPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
2595
2854
  ```tsx
2855
+ import { useState } from 'react';
2856
+ import {
2857
+ SKeyValueTable,
2858
+ SPopup,
2859
+ SSectionHeaderCard,
2860
+ STable,
2861
+ STabs,
2862
+ type SKeyValueField,
2863
+ type STableColumn,
2864
+ } from 'sellmate-design-system-react';
2865
+ import { ORDER_TABS, useTransferOrder } from './api';
2866
+ import { useParams } from './router';
2867
+
2596
2868
  // 1) 목록에서 별도 창을 연다 — 창 크기 = 콘텐츠 크기
2597
- function openDetailPopup(orderId: string) {
2869
+ export function openTransferOrderPopup(orderId: string) {
2598
2870
  window.open(
2599
2871
  `${window.location.origin}/popup/transfer-orders/${orderId}`,
2600
2872
  `transfer-order-${orderId}`,
@@ -2602,21 +2874,55 @@ function openDetailPopup(orderId: string) {
2602
2874
  );
2603
2875
  }
2604
2876
 
2605
- // 2) 그 라우트의 루트는 SPopup 하나다 (조회만 → 푸터 없음)
2877
+ const summaryFields: SKeyValueField[][] = [
2878
+ [
2879
+ { name: 'from', label: '출발 창고', type: 'text' },
2880
+ { name: 'to', label: '도착 창고', type: 'text' },
2881
+ { name: 'requestedAt', label: '요청일', type: 'text' },
2882
+ ],
2883
+ ];
2884
+
2885
+ // 정렬·폭은 값의 성격에서 나온다 (§3-4) — 코드·일시·닫힌 집합은 center, 양은 right, 자유 텍스트는 기준 폭
2886
+ const itemColumns: STableColumn[] = [
2887
+ { name: 'sku', label: '품목 코드', field: 'sku', width: 140, align: 'center' },
2888
+ { name: 'name', label: '품목명', field: 'name', width: 240 },
2889
+ { name: 'qty', label: '수량', field: 'qty', width: 100, align: 'right' },
2890
+ ];
2891
+
2892
+ const historyColumns: STableColumn[] = [
2893
+ { name: 'at', label: '일시', field: 'at', width: 160, align: 'center' },
2894
+ // 처리 종류는 닫힌 값 집합(요청·승인 …) → center
2895
+ { name: 'action', label: '처리', field: 'action', width: 100, align: 'center' },
2896
+ { name: 'by', label: '처리자', field: 'by', width: 120 },
2897
+ ];
2898
+
2899
+ // 2) 그 라우트의 루트는 SPopup 하나다 — 앱 셸(SLayout·SGnb·SPage)이 없다. 조회만 하므로 푸터도 없다
2606
2900
  export default function TransferOrderPopupPage() {
2901
+ const { orderId } = useParams();
2902
+ const order = useTransferOrder(orderId);
2903
+ const [tab, setTab] = useState('items');
2904
+
2607
2905
  return (
2608
2906
  // h-screen 으로 창 높이를 잡는다 — 이게 없으면 본문이 창 밖으로 자란다
2609
2907
  <SPopup className="h-screen" popupTitle="이동 오더 상세">
2610
2908
  {/* 본문 패딩은 SPopup 이 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다 */}
2611
2909
  <div className="flex flex-col gap-sd-12">
2612
- <SSectionHeaderCard>…</SSectionHeaderCard>
2613
- <STabs value={tab} tabs={TABS} onValueChange={setTab} />
2614
- <STable columns={columns} rows={rows} rowKey="id" />
2910
+ {/* 카드 안이 표 하나뿐이라 padding="none" 으로 가장자리까지 채운다 — 표는 자기 테두리를 끄고 위 모서리를 죽인다 (§2-2) */}
2911
+ <SSectionHeaderCard title={order.orderId} padding="none">
2912
+ <SKeyValueTable fields={summaryFields} values={order} bordered={false} radius="useTop" />
2913
+ </SSectionHeaderCard>
2914
+ <STabs value={tab} tabs={ORDER_TABS} onValueChange={setTab} />
2915
+ {tab === 'items' ? (
2916
+ <STable columns={itemColumns} rows={order.items} rowKey="id" resizable />
2917
+ ) : (
2918
+ <STable columns={historyColumns} rows={order.history} rowKey="id" resizable />
2919
+ )}
2615
2920
  </div>
2616
2921
  </SPopup>
2617
2922
  );
2618
2923
  }
2619
2924
  ```
2925
+ <!-- /recipe -->
2620
2926
 
2621
2927
  **높이는 라우트가 준다 — `SPopup` 에 `h-screen` 을 준다.** `SPopup` 자체는 부모 높이를 채우도록만 되어 있어서, 부모(라우트 루트·`body`)가 높이를 정해 주지 않으면 100% 가 풀려 본문이 창 밖으로 자란다. 그러면 헤더·푸터가 위아래로 밀려 창 안에 보이지 않는다. `h-screen` 이면 `html`·`body`·마운트 루트에 전역 CSS 를 걸지 않고도 창 높이가 바로 들어온다. (`body` 여백은 0 이어야 한다 — Tailwind preflight 가 이미 0 으로 만든다.)
2622
2928
 
@@ -2624,28 +2930,96 @@ export default function TransferOrderPopupPage() {
2624
2930
 
2625
2931
  **표를 담으면 본문이 아니라 표가 스크롤한다.** 본문이 통째로 스크롤되면 표 헤더와 페이지네이션 바가 위아래로 밀려 사라진다. `SPopup` 의 본문은 이미 남은 높이를 잡고 있으므로, 본문 루트에 `h-full min-h-0` 으로 세로 축을 잇고 표에 `min-h-0 flex-1` 을 준다. **툴바+표를 묶은 `div` 도 사슬의 한 칸이라 거기에도 `min-h-0 flex-1` 이 필요하다** (§4-2 목록 페이지와 같은 사슬이다 — 한 칸만 끊겨도 표가 높이를 못 잡는데 실패가 조용하다).
2626
2932
 
2933
+ <!-- recipe:src/recipes/ExcelFilePopupPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
2627
2934
  ```tsx
2628
- <SPopup
2629
- className="h-screen"
2630
- popupTitle="엑셀 파일 관리"
2631
- showFooter // 확정할 작업이 있을 때만 (§3-3-3)
2632
- submitButton={{ label: '저장' }}
2633
- onSubmit={save}
2634
- >
2635
- <div className="flex h-full min-h-0 flex-col gap-sd-12">
2636
- <SKeyValueTable fields={filterFields} values={filters} search
2637
- onChange={({ values }) => setFilters(values)} onSearch={fetchList} />
2935
+ import { useState } from 'react';
2936
+ import {
2937
+ SPopup,
2938
+ SSearchInput,
2939
+ STable,
2940
+ STableBar,
2941
+ type SRow,
2942
+ type STableColumn,
2943
+ } from 'sellmate-design-system-react';
2944
+ import { applyExcelFiles, useExcelFiles } from './api';
2638
2945
 
2639
- {/* 툴바+표 = 한 블록. 이 div 가 사슬의 한 칸이다 (§2-0) */}
2640
- <div className="flex min-h-0 flex-1 flex-col">
2641
- <STableBar className="border-b-0" total={total} />
2642
- {/* 표만 자기 안에서 스크롤한다 — 필터·툴바·푸터는 늘 보인다 */}
2643
- <STable className="min-h-0 flex-1" columns={columns} rows={rows} rowKey="id"
2644
- pagination={{ currentPage, lastPage }} />
2645
- </div>
2646
- </div>
2647
- </SPopup>
2946
+ // 정렬·폭은 값의 성격에서 나온다 (§3-4) — 자유 텍스트는 기준 폭 + resizable, 일자는 center, 양은 right
2947
+ const columns: STableColumn[] = [
2948
+ { name: 'fileName', label: '파일명', field: 'fileName', width: 320 },
2949
+ { name: 'uploadedAt', label: '업로드일', field: 'uploadedAt', width: 120, align: 'center' },
2950
+ {
2951
+ name: 'rows',
2952
+ label: '행 수',
2953
+ field: 'rows',
2954
+ width: 100,
2955
+ align: 'right',
2956
+ format: (v: unknown) => Number(v).toLocaleString(),
2957
+ },
2958
+ ];
2959
+
2960
+ /**
2961
+ * 표를 담은 팝업 — 본문이 아니라 표가 스크롤한다. 툴바·푸터는 늘 보인다.
2962
+ * SPopup 의 본문은 이미 남은 높이를 잡고 있으므로, 본문 루트부터 표까지 세로 축을 잇는다 (§4-2 와 같은 사슬).
2963
+ */
2964
+ export default function ExcelFilePopupPage() {
2965
+ // 입력 중인 검색어와 Enter 로 확정된 조회 조건은 따로 든다
2966
+ const [keyword, setKeyword] = useState('');
2967
+ const [query, setQuery] = useState({ keyword: '', page: 1, rowsPerPage: 20 });
2968
+ const [selected, setSelected] = useState<SRow[]>([]);
2969
+ const { rows, total, lastPage } = useExcelFiles(query);
2970
+
2971
+ const save = () => applyExcelFiles(selected.map(row => Number(row.id)));
2972
+
2973
+ return (
2974
+ <SPopup
2975
+ className="h-screen"
2976
+ popupTitle="엑셀 파일 관리"
2977
+ showFooter // 확정할 작업이 있을 때만 (§3-3-3)
2978
+ submitButton={{ label: '저장', disabled: !selected.length }}
2979
+ onSubmit={save}
2980
+ >
2981
+ {/* 툴바+표 = 한 블록이자 본문 루트. h-full min-h-0 → 표의 min-h-0 flex-1 로 세로 축이 이어진다.
2982
+ 필터 표가 위에 더 서면 이 블록을 한 겹 감싸고 여기에 min-h-0 flex-1 을 준다 (§4-2 와 같은 사슬) */}
2983
+ <div className="flex h-full min-h-0 flex-col">
2984
+ {/* 조건이 검색어 하나뿐이면 필터 표를 세우지 않는다 — 목록을 좁히기만 하는 검색어는
2985
+ 폼 필드가 아니라 SSearchInput 이다 (§3-7-1). 툴바 우측에 둔다 */}
2986
+ <STableBar
2987
+ className="border-b-0"
2988
+ total={total}
2989
+ selected={selected.length}
2990
+ rightActions={
2991
+ <SSearchInput
2992
+ value={keyword}
2993
+ onValueChange={value => {
2994
+ setKeyword(value);
2995
+ // 지우기(×)는 값만 비우고 onSearch 를 부르지 않는다 — 걸려 있던 조건도 여기서 함께 푼다
2996
+ if (!value) setQuery(q => ({ ...q, keyword: '', page: 1 }));
2997
+ }}
2998
+ onSearch={value => setQuery(q => ({ ...q, keyword: value, page: 1 }))}
2999
+ placeholder="파일명 검색"
3000
+ width="md"
3001
+ clearable
3002
+ />
3003
+ }
3004
+ />
3005
+ <STable
3006
+ className="min-h-0 flex-1"
3007
+ columns={columns}
3008
+ rows={rows}
3009
+ rowKey="id"
3010
+ resizable
3011
+ selectable
3012
+ selected={selected}
3013
+ onSelectedChange={setSelected}
3014
+ pagination={{ page: query.page, rowsPerPage: query.rowsPerPage, lastPage }}
3015
+ onPageChange={page => setQuery(q => ({ ...q, page }))}
3016
+ />
3017
+ </div>
3018
+ </SPopup>
3019
+ );
3020
+ }
2648
3021
  ```
3022
+ <!-- /recipe -->
2649
3023
 
2650
3024
  조회만 하고 표가 없는 팝업은 이 사슬이 필요 없다 — 본문이 통째로 스크롤되어도 헤더는 고정이고 밀려날 푸터가 없다.
2651
3025
 
@@ -2699,7 +3073,7 @@ export default function TransferOrderPopupPage() {
2699
3073
  - [ ] 서버에서 페이지 단위로 받는 `SSelect` 에 `onReachEnd` 와 `hasMore`·`loading`·`serverSearch` 를 함께 줬는가, 늦게 온 응답을 버리는 cleanup 이 있는가 (§3-7-2 — 렌더 최적화는 DS 가 알아서 한다)
2700
3074
  - [ ] `SKeyValueTable` 의 짧은 행에 `tdColSpan` 을 주어 전체 열 수를 채웠는가 (§4-3 — 안 채우면 그 구간의 행 구분선이 끊긴다)
2701
3075
  - [ ] 날짜·시간 필드가 섞인 `SKeyValueTable` 에서 오른쪽 끝을 맞출지 정했는가 (§4-3 `fieldWidth` — 필터는 `"fill"`, 등록·수정 폼은 기본값. 필드마다 `maxWidth` 를 주지 않는다)
2702
- - [ ] 하단 버튼이 양끝 분리(`justify-between`)이고, 되돌리기가 왼쪽 끝 · 실행이 오른쪽 끝인가
3076
+ - [ ] 하단 버튼이 양끝 분리(`justify-between`)이고, 되돌리기가 왼쪽 끝 · 실행이 오른쪽 끝인가 (되돌리는 액션이 없어도 양끝 분리는 그대로 — 오른쪽에 모으지 않고 위계가 낮은 쪽을 왼쪽 끝에 둔다)
2703
3077
  - [ ] 페이지에 `color="primary"` 채움 버튼이 **1개뿐**인가 (`danger` 채움도 1개, `SDropdownButton` 포함)
2704
3078
  - [ ] 버튼 `size` 가 위치 규칙과 맞는가 (행 내부 `xs` / 화면 액션 `sm` / 모달 푸터 `md`)
2705
3079
  - [ ] 파괴 액션의 채움/outline 이 **적용 범위 + 위험도** 기준과 맞는가