sellmate-design-system-react 9.0.0-beta.79 → 9.0.0-beta.80
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.
- package/AGENTS.md +2027 -10
- package/dist/components/SExpansionItem/README.md +2 -2
- package/dist/components/SExpansionItem/SExpansionItem.d.ts +8 -2
- package/dist/components/SListItem/README.md +2 -2
- package/dist/components/SListItem/SListItem.d.ts +8 -2
- package/dist/components/SSkeleton/README.md +55 -0
- package/dist/components/SSkeleton/SSkeleton.d.ts +39 -0
- package/dist/components/SSkeleton/index.d.ts +2 -0
- package/dist/components/SSkeleton/skeleton.config.d.ts +22 -0
- package/dist/index.cjs +173 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +170 -9
- package/dist/index.js.map +1 -1
- package/dist/llms-full.txt +2203 -129
- package/dist/llms.txt +2029 -12
- package/dist/recipes/CategoryManagePage.d.ts +1 -0
- package/dist/recipes/ConsultConsolePage.d.ts +1 -0
- package/dist/recipes/ProductBulkUploadPage.d.ts +1 -0
- package/dist/recipes/ProductOverlayPage.d.ts +1 -0
- package/dist/recipes/SalesDashboardPage.d.ts +1 -0
- package/dist/recipes/SettingsPage.d.ts +1 -0
- package/dist/recipes/StoreSetupPage.d.ts +1 -0
- package/dist/recipes/api.d.ts +129 -0
- package/dist/styles.css +124 -0
- package/dist/theme.css +9 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
| **내비게이션** | `STabs` `SPagination` `SStepper` |
|
|
48
48
|
| **차트** | `SBarChart`(막대 그래프 — 항목끼리 크기를 견준다. `stacked` 로 항목 안의 구성까지) `SLineChart`(꺾은선 그래프 — 순서가 있는 항목의 추이를 본다. `area` 로 크기까지, `stacked` 로 구성까지) `SPieChart`(원그래프 — 한 시점의 전체를 몇 개의 몫으로 나눈다) `SDonutChart`(도넛 그래프 — 가운데가 빈 원그래프. 구멍에 글자를 두지 않는다) |
|
|
49
49
|
| **표시·상태** | `STag` `SBadge` `SIcon` `SLogo`(셀메이트·서비스 로고를 아이콘처럼 — name 으로 서비스, mode 로 배경, direction 으로 가로·세로, size 는 높이다 — `"auto"` 면 부모 폭을 채운다) `SImage` `SCallout` `SContextAlert`(한 줄짜리 맥락 알림 — 오른쪽에 후속 동작 버튼) `SGuide` |
|
|
50
|
-
| **진행·로딩** | `SLinearProgress` `SCircleProgress` `SLoadingContainer` `SLoadingModal` |
|
|
50
|
+
| **진행·로딩** | `SLinearProgress` `SCircleProgress` `SSkeleton`(처음 불러오는 동안 콘텐츠 자리를 같은 모양으로 채운다) `SLoadingContainer` `SLoadingModal` |
|
|
51
51
|
| **오버레이** | `STooltip` `SPopover` `SPopup` `SDrawer` `SPortal` |
|
|
52
52
|
| **모달** | `SModal.confirm()` `SModal.create()` + `SActionModal` `SConfirmModal` `SModalOutlet`(앱 루트 1회) |
|
|
53
53
|
| **알림** | `SToast` `SToastContainer` |
|
|
@@ -152,7 +152,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
|
|
|
152
152
|
|
|
153
153
|
**`toLocaleDateString()` 은 쓰지 않는다** — 로케일에 따라 결과가 바뀌어 표기를 지킬 수 없다.
|
|
154
154
|
|
|
155
|
-
**대화 화면의
|
|
155
|
+
**대화 화면의 시각과 날짜는 예외다.** `SChatMessage` 의 `time` 처럼 말풍선에 붙는 시각은 `오후 5:52` 로, 대화 중간의 날짜 구분선은 `2026년 10월 6일 화요일` 로 쓴다. 여기서 시각·날짜는 정렬하거나 크기를 비교하는 값이 아니라 대화의 흐름을 짚는 표시라, 자릿수를 맞춘 `17:52`·`2026-10-06` 보다 말하듯 읽히는 표기가 맞다. 이 표기도 `toLocaleDateString()` 으로 만들지 않는다 — 연·월·일과 요일을 직접 이어 붙인다(§4-12). **이 예외는 대화 말풍선과 대화의 날짜 구분선에만 열린다** — 같은 화면이라도 표·상세·목록에 들어가는 일시는 그대로 `YYYY-MM-DD HH:mm` 이다.
|
|
156
156
|
|
|
157
157
|
---
|
|
158
158
|
|
|
@@ -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` `SContextAlert` `SBarChart` `SLineChart` `SPieChart` `SDonutChart` `STabs` `SStepper` `SPagination` `SDivider` |
|
|
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` |
|
|
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` `SSkeleton` |
|
|
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
|
|
|
@@ -225,6 +225,8 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
|
|
|
225
225
|
7. 하단 액션 되돌리기 왼쪽 · 실행 오른쪽 (§4-3)
|
|
226
226
|
```
|
|
227
227
|
|
|
228
|
+
**`STabs`·`SStepper` 는 아래 블록 전체가 어느 관점·단계인지 말하므로 순서 목록 맨 위(페이지 제목 바로 아래, 상시 안내보다 앞)에 선다** (§4-10·§4-11).
|
|
229
|
+
|
|
228
230
|
간격은 층마다 다르다 — 블록 ↔ 블록은 `gap-sd-12`, 요소 ↔ 요소는 `gap-sd-8` 이 기본이고, 같은 컴포넌트를 나열할 때는 컴포넌트별 그룹 간격이 따로 있다. 전부 §2-2 에 있다.
|
|
229
231
|
|
|
230
232
|
#### 툴바(4)와 본문 표(5)는 한 블록이다 — 붙인다
|
|
@@ -376,7 +378,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
|
|
|
376
378
|
|
|
377
379
|
적용은 **화면 단위가 아니라 영역 단위**다. 한 화면 안에서도 칸마다 다르다.
|
|
378
380
|
|
|
379
|
-
| 영역 (상담 콘솔 예) | 안쪽 여백 | 이유 |
|
|
381
|
+
| 영역 (상담 콘솔 예 — 코드는 §4-12) | 안쪽 여백 | 이유 |
|
|
380
382
|
| --- | --- | --- |
|
|
381
383
|
| 좌측 상담 목록 | `p-sd-16` | 같은 항목 반복 |
|
|
382
384
|
| 중앙 대화 | `p-sd-24` | 말풍선·버튼 묶음·시스템 메시지 혼재 |
|
|
@@ -653,6 +655,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
|
|
|
653
655
|
| --- | --- | --- |
|
|
654
656
|
| 화면 전체를 잠그고 기다리게 한다 | `loading.show()` — 진행률·실패 후 재시도 버튼이 필요하면 `SLoadingModal` (`SModal.loading()`) | §3-3-6 |
|
|
655
657
|
| 특정 영역만 덮고 기다리게 한다 | `SLoadingContainer` | §3-3-6 |
|
|
658
|
+
| 처음 불러오는 동안 올 콘텐츠의 자리를 같은 모양으로 채운다 | `SSkeleton` | §3-7-15 |
|
|
656
659
|
| 진행률을 가로 막대로 보여준다 | `SLinearProgress` | §3-7-9 |
|
|
657
660
|
| 진행률·대기를 원형으로 보여준다 | `SCircleProgress` | §3-7-9 |
|
|
658
661
|
|
|
@@ -739,12 +742,16 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
|
|
|
739
742
|
|
|
740
743
|
```tsx
|
|
741
744
|
<SActionModal modalTitle="발주 검토" button={{ label: '확정', onClick: submit }}>
|
|
742
|
-
{/*
|
|
743
|
-
<
|
|
745
|
+
{/* 본문은 flex 컨테이너가 아니다 — 세로 축을 잇는 상자를 한 겹 둔다 */}
|
|
746
|
+
<div className="flex h-full min-h-0 flex-col gap-sd-12">
|
|
747
|
+
<SContextAlert intent="warning" messages={['품절 상품 2건은 제외됩니다.']} />
|
|
748
|
+
{/* 표가 남은 높이를 먹고 자기 안에서 스크롤한다 — 위 안내·헤더·푸터는 늘 보인다 */}
|
|
749
|
+
<STable className="min-h-0 flex-1" columns={columns} rows={rows} />
|
|
750
|
+
</div>
|
|
744
751
|
</SActionModal>
|
|
745
752
|
```
|
|
746
753
|
|
|
747
|
-
본문(`overflow-auto` 영역)이 통째로 스크롤되면 표 헤더와 합계
|
|
754
|
+
본문(`overflow-auto` 영역)이 통째로 스크롤되면 표 헤더와 합계 줄, 위의 안내까지 밀려 사라진다. **`SActionModal` 의 본문 상자는 높이는 갖지만 flex 컨테이너가 아니다** — 그래서 표에 `min-h-0 flex-1` 만 주면 아무 일도 일어나지 않고 본문이 통째로 스크롤된다. 본문 바로 아래에 `flex h-full min-h-0 flex-col` 상자를 두고 그 안에서 표에 `min-h-0 flex-1` 을 준다 (§4-2 목록 페이지와 같은 사슬이다). 표 하나뿐이어도 이 상자는 필요하다.
|
|
748
755
|
|
|
749
756
|
**성격이 먼저 둘로 갈린다.**
|
|
750
757
|
|
|
@@ -1958,12 +1965,21 @@ const [selectedId, setSelectedId] = useState<string>();
|
|
|
1958
1965
|
{/* 하위가 없는 항목(전체·미분류)은 SExpansionItem 이 아니라 SListItem 이다 */}
|
|
1959
1966
|
<SListItem title="전체" selected={selectedId === 'all'} onClick={() => setSelectedId('all')} />
|
|
1960
1967
|
<SExpansionItem title="조직">
|
|
1968
|
+
{/* 펼친 항목 안에는 다음 단계 항목을 바로 넣는다 — depth 는 주지 않는다 */}
|
|
1961
1969
|
<SListItem title="영업팀" selected={selectedId === 'sales'} onClick={() => setSelectedId('sales')} />
|
|
1962
1970
|
</SExpansionItem>
|
|
1963
1971
|
</SExpansionList>
|
|
1964
1972
|
```
|
|
1965
1973
|
|
|
1966
|
-
- `
|
|
1974
|
+
- **`SExpansionList` 는 루트에 하나만 둔다.** 펼친 항목의 본문이 목록(`SListItem`·`SExpansionItem` 만)이면 리스트가 그 본문을 다음 단계로 다룬다. 그래서 하위 항목에도 아래가 따라온다 — 직접 주지 않는다.
|
|
1975
|
+
- `clickable` — 리스트 안의 `SListItem` 은 기본으로 켜진다. `clickable` 없이 `selected` 만 주면 표시가 안 나오는 함정(§3-7-6)을 막는 값이다. 표시 전용 항목이면 `clickable={false}` 를 준다.
|
|
1976
|
+
- `depth` — 중첩에서 정해진다. 들여쓰기를 위해 `depth={2}` 를 손으로 적지 않는다.
|
|
1977
|
+
- 구분선·아코디언 — 단계마다 리스트가 그리고, 같은 단계에서 하나만 열린다.
|
|
1978
|
+
- 본문 여백 — 목록 본문이면 `bodyPadding` 이 꺼져 항목이 가장자리까지 찬다.
|
|
1979
|
+
- 직접 준 `depth`·`clickable`·`bodyPadding` 은 언제나 이 기본값보다 우선한다. 안쪽에 `SExpansionList` 를 직접 둔 코드도 그대로 동작한다 (다시 감싸지 않는다).
|
|
1980
|
+
- **본문에 목록이 아닌 내용이 섞이면 다음 단계로 보지 않는다.** 설명 문단·폼이 든 본문은 손대지 않으므로, 그 안의 `SListItem` 에는 위 기본값이 따라오지 않는다.
|
|
1981
|
+
- **리스트 밖 단독 `SExpansionItem` 은 본문을 다루지 않는다.** 선택하는 목록을 담으려면 `SExpansionList` 로 감싼다.
|
|
1982
|
+
|
|
1967
1983
|
- **하위를 가지지 않는 항목은 `SExpansionItem` 이 아니라 `SListItem`** 으로 둔다. 펼칠 것이 없는데 펼침 항목으로 만들면 화살표만 남는다.
|
|
1968
1984
|
|
|
1969
1985
|
#### 3-7-8. SCard vs SSectionHeaderCard
|
|
@@ -2140,13 +2156,40 @@ const [selectedId, setSelectedId] = useState<string>();
|
|
|
2140
2156
|
{/* 0 이 1 로 바뀐 뒤 도착하므로 저장 직전의 v < 1 가드는 영원히 걸리지 않는다 */}
|
|
2141
2157
|
```
|
|
2142
2158
|
|
|
2159
|
+
#### 3-7-15. 로딩 중 자리 — SSkeleton vs SLoadingContainer vs SCircleProgress
|
|
2160
|
+
|
|
2161
|
+
| 판별 | 사용 |
|
|
2162
|
+
| --- | --- |
|
|
2163
|
+
| **처음 불러오는 중**이고, 올 콘텐츠의 모양(목록 행·카드·폼)을 안다 | `SSkeleton` |
|
|
2164
|
+
| 이미 보이는 콘텐츠를 **다시 불러오거나 저장하는 중**이라 그 영역의 입력을 막아야 한다 | `SLoadingContainer` |
|
|
2165
|
+
| 모양을 알 수 없는 작은 자리에서 끝나는 시점을 모른 채 기다린다 | `SCircleProgress` `indeterminate` |
|
|
2166
|
+
|
|
2167
|
+
`SSkeleton` 은 입력을 막지 않는다 — 자리만 잡는다. 데이터가 오면 같은 자리에 실제 컴포넌트로 **바꿔 그린다**(겹쳐 두지 않는다).
|
|
2168
|
+
|
|
2169
|
+
- **실제 레이아웃을 그대로 따라 그린다.** 폼 컨트롤 자리는 `type="input"`·`"button"`·`"checkbox"` 처럼 DS 모양을 쓴다. 해당 컴포넌트 기본 size 의 크기·모서리를 토큰으로 넣으므로 **높이·radius 를 직접 주지 않는다** — 직접 주면 데이터가 왔을 때 레이아웃이 튄다. checkbox·radio·switch 는 크기를 바꾸지 않는다.
|
|
2170
|
+
- **텍스트 줄은 `type="text"` 를 실제 텍스트와 같은 typo 클래스 안에 둔다.** 줄 높이를 부모 line-height 에서 받으므로 typo 밖에 두면 높이가 어긋난다. `text` 에 `height` 를 주지 않는다 — 높이가 필요하면 `rect` 다.
|
|
2171
|
+
- **원은 `size` 하나로 준다.** `width`·`height` 를 따로 주면 타원이 된다.
|
|
2172
|
+
- **넓은 영역에는 `pulse` 를 쓰지 않는다.** 블록 전체가 줄었다 커져 레이아웃이 흔들려 보인다. 기본 `wave` 를 쓰고, 한 화면에 아주 많으면 `none`.
|
|
2173
|
+
- **로딩 중이라는 사실은 감싸는 영역에 `aria-busy` 로 알린다.** `SSkeleton` 자체는 장식이라 보조기기에 숨겨져 있다.
|
|
2174
|
+
|
|
2175
|
+
```tsx
|
|
2176
|
+
✅ <div aria-busy={loading}>
|
|
2177
|
+
{loading
|
|
2178
|
+
? <div className="typo-body-md-default"><SSkeleton type="text" width="40%" /><SSkeleton type="text" /></div>
|
|
2179
|
+
: <p className="typo-body-md-default">{data.summary}</p>}
|
|
2180
|
+
</div>
|
|
2181
|
+
|
|
2182
|
+
❌ <SSkeleton type="input" height={40} /> {/* 실제 SInput 과 높이가 달라 데이터가 오면 튄다 */}
|
|
2183
|
+
❌ <SSkeleton type="text" height={24} /> {/* text 는 세로 절반만 칠한다 — rect 를 쓴다 */}
|
|
2184
|
+
```
|
|
2185
|
+
|
|
2143
2186
|
---
|
|
2144
2187
|
|
|
2145
2188
|
## 4. 페이지 레시피 — 표준 골격
|
|
2146
2189
|
|
|
2147
2190
|
> 새 페이지는 반드시 아래 골격에서 시작한다. 임의 골격을 발명하지 않는다.
|
|
2148
2191
|
>
|
|
2149
|
-
> **아래 레시피는 §2-0 조합 문법으로 유도된 결과다.** 여기 없는 화면(
|
|
2192
|
+
> **아래 레시피는 §2-0 조합 문법으로 유도된 결과다.** 여기 없는 화면(일정 캘린더·권한 설정 등)을 만들 때는 임의로 짜지 말고 §2-0 의 층 구조·포함 규칙·블록 순서로 직접 유도한다.
|
|
2150
2193
|
>
|
|
2151
2194
|
> **레시피 코드의 `./AppShell` 은 §4-1 의 셸, `./api`·`./router` 는 앱의 데이터 계층·라우터 자리다.** 각 레시피는 Storybook `Recipes/…` 에서 실제로 띄워 볼 수 있고, 아래에 적힌 분기(칩 필터·섹션 나눔 등)의 변형 코드도 거기 있다.
|
|
2152
2195
|
>
|
|
@@ -3232,6 +3275,1980 @@ export default function ExcelFilePopupPage() {
|
|
|
3232
3275
|
|
|
3233
3276
|
조회만 하고 표가 없는 팝업은 이 사슬이 필요 없다 — 본문이 통째로 스크롤되어도 헤더는 고정이고 밀려날 푸터가 없다.
|
|
3234
3277
|
|
|
3278
|
+
### 4-8. 대시보드 — 요약 타일 + 차트
|
|
3279
|
+
|
|
3280
|
+
구조: **페이지 헤더 → 조회 조건(`SKeyValueTable`) → 요약 타일 줄(`SCard`) → 차트 섹션(`SSectionHeaderCard` + 차트)**
|
|
3281
|
+
|
|
3282
|
+
- **조회 조건도 필터다 — `SKeyValueTable` 로 만든다** (§4 핵심 원칙). 기간은 지표의 전제라 비울 수 없으므로 기간 피커에는 `clearable` 을 켜지 않는다. 나머지 조건(판매처 등)은 목록 필터와 같이 `clearable` 이다 (§3-7-4).
|
|
3283
|
+
- **요약 타일은 `SCard` 다.** 제목이 붙는 섹션이 아니라 흰 면 하나라서다 (§3-7-8). 이 레시피의 페이지 바탕은 흰 면이라 `bordered` 로 경계를 긋는다. 안쪽 여백은 직접 만든 컨테이너 규칙(§2-2)을 따른다.
|
|
3284
|
+
- 라벨 → 메인 수치(`typo-heading-xl`) → 증감 순으로 쌓는다. 메인 수치 프리셋은 제목에 쓰지 않는다 (§2-1).
|
|
3285
|
+
- 증감은 **부호와 색을 함께** 준다 — 색만으로 늘었는지 줄었는지 말하지 않는다. 색은 `text-fg-success` / `text-fg-danger` 다.
|
|
3286
|
+
- **타일·차트를 가로로 늘어놓는 줄은 `grid` 로 만들고, 그 `gap` 을 블록 간격(`gap-sd-12`)과 같게 둔다.** 줄 자체가 블록 스택의 한 칸이고, 줄 안의 간격이 세로 블록 간격과 같아야 격자가 한 리듬으로 읽힌다.
|
|
3287
|
+
- **차트는 `SSectionHeaderCard` 에 담고, 차트 이름은 카드의 `title` 이 맡는다.** 차트의 `title` 을 함께 주면 제목이 두 줄이 된다. 단위(`unit`)는 차트가 오른쪽 위에 적는다. 섹션 카드가 여럿이라 `marker` 를 켠다 (§4-5).
|
|
3288
|
+
- 차트 종류는 §3-0 B 로 고른다 — 날짜순 흐름은 `SLineChart`, 항목끼리 견주기는 `SBarChart`, 전체의 몫은 `SPieChart`/`SDonutChart`. **도넛의 합계는 구멍이 아니라 카드 `subtitle` 에 적는다.**
|
|
3289
|
+
- **차트는 "어느 쪽이 큰가" 까지만 답한다.** 정확한 값이 필요한 사용자를 위해 점·막대 클릭(`onPointClick`·`onBarClick`)을 해당 목록 화면으로 잇는다 — 대시보드 안에 같은 값의 표를 또 두지 않는다.
|
|
3290
|
+
- **높이 모드는 `auto` 다.** 차트 블록은 높이(`height`)가 정해져 있고 그 합이 창보다 길어질 수 있어 페이지가 스크롤하는 예외에 해당한다 — `contentHeight="auto"` 와 `scrollEndSpacing` 을 함께 켠다 (§2-2 "페이지 높이").
|
|
3291
|
+
|
|
3292
|
+
<!-- recipe:src/recipes/SalesDashboardPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
|
|
3293
|
+
```tsx
|
|
3294
|
+
import { useState } from 'react';
|
|
3295
|
+
import {
|
|
3296
|
+
SBarChart,
|
|
3297
|
+
SCard,
|
|
3298
|
+
SDonutChart,
|
|
3299
|
+
SKeyValueTable,
|
|
3300
|
+
SLineChart,
|
|
3301
|
+
SSectionHeaderCard,
|
|
3302
|
+
type SDateRangeValue,
|
|
3303
|
+
type SKeyValueField,
|
|
3304
|
+
} from 'sellmate-design-system-react';
|
|
3305
|
+
import AppShell from './AppShell';
|
|
3306
|
+
import { CHANNEL_OPTIONS, useSalesDashboard } from './api';
|
|
3307
|
+
import { useNavigate } from './router';
|
|
3308
|
+
|
|
3309
|
+
/** 대시보드의 조회 조건도 표다 — 목록 페이지의 필터와 같은 SKeyValueTable (§4-2) */
|
|
3310
|
+
const filterFields: SKeyValueField[][] = [
|
|
3311
|
+
[
|
|
3312
|
+
// 기간은 지표의 전제라 비울 수 없다 — clearable 을 켜지 않는다 (§3-7-4)
|
|
3313
|
+
{ name: 'period', label: '기간', type: 'date-range-picker' },
|
|
3314
|
+
{
|
|
3315
|
+
name: 'channel',
|
|
3316
|
+
label: '판매처',
|
|
3317
|
+
type: 'select',
|
|
3318
|
+
options: { options: CHANNEL_OPTIONS, valueAsPrimitive: true, clearable: true },
|
|
3319
|
+
},
|
|
3320
|
+
],
|
|
3321
|
+
];
|
|
3322
|
+
|
|
3323
|
+
const won = (v: number) => `${v.toLocaleString()}원`;
|
|
3324
|
+
|
|
3325
|
+
export default function SalesDashboardPage() {
|
|
3326
|
+
const navigate = useNavigate();
|
|
3327
|
+
const [filter, setFilter] = useState<{ period: SDateRangeValue; channel?: string }>({
|
|
3328
|
+
period: ['2026-09-01', '2026-09-14'],
|
|
3329
|
+
});
|
|
3330
|
+
const { summary, daily, channelShare, topProducts } = useSalesDashboard(filter);
|
|
3331
|
+
const channelTotal = channelShare.reduce((sum, s) => sum + s.value, 0);
|
|
3332
|
+
|
|
3333
|
+
return (
|
|
3334
|
+
// 블록 높이를 차트가 정하고 그 합이 창보다 길어질 수 있다 — 페이지가 스크롤하는 예외라
|
|
3335
|
+
// contentHeight="auto" + scrollEndSpacing 이다 (§2-2 "페이지 높이")
|
|
3336
|
+
<AppShell header={{ variant: 'bar', title: '매출 현황' }} contentHeight="auto" scrollEndSpacing>
|
|
3337
|
+
<div className="flex flex-col gap-sd-12">
|
|
3338
|
+
<SKeyValueTable
|
|
3339
|
+
fields={filterFields}
|
|
3340
|
+
values={filter}
|
|
3341
|
+
onChange={({ values }) => setFilter(values as typeof filter)}
|
|
3342
|
+
fieldWidth="fill"
|
|
3343
|
+
/>
|
|
3344
|
+
|
|
3345
|
+
{/* 요약 타일 — 제목이 붙는 섹션이 아니라 흰 면 하나라 SCard 다 (§3-7-8).
|
|
3346
|
+
타일들은 한 줄의 블록이다 — 그리드의 gap 을 블록 간격(gap-sd-12)과 같게 두어 리듬을 잇는다.
|
|
3347
|
+
페이지 바탕이 흰 면(frame)이라 카드는 bordered 로 경계를 긋는다 */}
|
|
3348
|
+
<div className="grid grid-cols-4 gap-sd-12">
|
|
3349
|
+
{summary.map(s => (
|
|
3350
|
+
<SCard key={s.label} bordered className="flex flex-col gap-sd-4 p-sd-16">
|
|
3351
|
+
<span className="typo-body-sm-default text-fg-secondary">{s.label}</span>
|
|
3352
|
+
{/* 카드 안의 메인 수치는 typo-heading-xl — 제목에는 쓰지 않는다 (§2-1) */}
|
|
3353
|
+
<span className="typo-heading-xl">
|
|
3354
|
+
{s.value.toLocaleString()}
|
|
3355
|
+
{s.unit}
|
|
3356
|
+
</span>
|
|
3357
|
+
{/* 증감은 숫자 앞 부호와 색으로 함께 말한다 — 색만으로 뜻을 싣지 않는다 */}
|
|
3358
|
+
<span className="typo-body-sm-default text-fg-tertiary">
|
|
3359
|
+
전기간 대비{' '}
|
|
3360
|
+
<span className={s.change >= 0 ? 'text-fg-success' : 'text-fg-danger'}>
|
|
3361
|
+
{s.change >= 0 ? '+' : ''}
|
|
3362
|
+
{s.change.toLocaleString()}%
|
|
3363
|
+
</span>
|
|
3364
|
+
</span>
|
|
3365
|
+
</SCard>
|
|
3366
|
+
))}
|
|
3367
|
+
</div>
|
|
3368
|
+
|
|
3369
|
+
{/* 차트는 섹션 카드에 담는다. 차트 이름은 카드의 title 이 맡으므로 차트의 title 은 주지 않는다 —
|
|
3370
|
+
둘 다 주면 제목이 두 줄이 된다. 단위(unit)는 차트가 오른쪽 위에 적는다.
|
|
3371
|
+
한 페이지에 섹션 카드가 여럿이라 marker 를 켠다 (§4-5) */}
|
|
3372
|
+
{/* 날짜순 흐름이라 선 — 순서가 있는 항목의 추이 (§3-0 B). 항목(날짜)이 많아 한 줄을 다 쓴다 */}
|
|
3373
|
+
<SSectionHeaderCard title="일별 매출" marker>
|
|
3374
|
+
<SLineChart
|
|
3375
|
+
categories={daily.categories}
|
|
3376
|
+
series={[{ name: '매출', data: daily.data }]}
|
|
3377
|
+
unit="원"
|
|
3378
|
+
area
|
|
3379
|
+
showLegend={false}
|
|
3380
|
+
height={280}
|
|
3381
|
+
formatValue={won}
|
|
3382
|
+
// 차트는 "어느 쪽이 큰가" 까지만 답한다 — 정확한 값은 그날의 주문 목록에서 본다
|
|
3383
|
+
onPointClick={p => navigate(`/orders?date=${daily.dates[p.categoryIndex]}`)}
|
|
3384
|
+
/>
|
|
3385
|
+
</SSectionHeaderCard>
|
|
3386
|
+
|
|
3387
|
+
<div className="grid grid-cols-2 gap-sd-12">
|
|
3388
|
+
{/* 합계는 도넛 구멍이 아니라 카드 subtitle 에 적는다 (§3-0 B) */}
|
|
3389
|
+
<SSectionHeaderCard title="판매처별 비중" marker subtitle={`합계 ${won(channelTotal)}`}>
|
|
3390
|
+
<SDonutChart
|
|
3391
|
+
data={channelShare}
|
|
3392
|
+
legendPosition="bottom"
|
|
3393
|
+
formatValue={won}
|
|
3394
|
+
// 그림은 폭만큼의 정사각형이다 — 칸을 다 채우면 옆 카드보다 훨씬 길어지므로
|
|
3395
|
+
// 화면 고유 치수로 폭을 묶는다 (§1-2)
|
|
3396
|
+
className="mx-auto max-w-[280px]"
|
|
3397
|
+
/>
|
|
3398
|
+
</SSectionHeaderCard>
|
|
3399
|
+
|
|
3400
|
+
{/* 항목끼리 크기를 견주므로 막대. 상품명이 길어 가로 막대로 눕힌다 */}
|
|
3401
|
+
<SSectionHeaderCard title="상품별 판매량 TOP 5" marker>
|
|
3402
|
+
<SBarChart
|
|
3403
|
+
categories={topProducts.categories}
|
|
3404
|
+
series={[{ name: '판매량', data: topProducts.data }]}
|
|
3405
|
+
unit="개"
|
|
3406
|
+
direction="horizontal"
|
|
3407
|
+
showLegend={false}
|
|
3408
|
+
height={320}
|
|
3409
|
+
formatValue={v => `${v.toLocaleString()}개`}
|
|
3410
|
+
/>
|
|
3411
|
+
</SSectionHeaderCard>
|
|
3412
|
+
</div>
|
|
3413
|
+
</div>
|
|
3414
|
+
</AppShell>
|
|
3415
|
+
);
|
|
3416
|
+
}
|
|
3417
|
+
```
|
|
3418
|
+
<!-- /recipe -->
|
|
3419
|
+
|
|
3420
|
+
### 4-9. 분할 화면 — 왼쪽 목록 + 오른쪽 상세
|
|
3421
|
+
|
|
3422
|
+
왼쪽에서 하나를 고르면 오른쪽에 그 상세가 열리는 화면이다 (카테고리·창고·코드 관리 등). 상세로 **페이지를 이동하지 않고** 목록을 보면서 연달아 고쳐야 할 때 쓴다. 목록을 훑는 것이 주목적이고 상세는 가끔 여는 화면이면 §4-2 목록 → §4-4 상세 페이지 이동이 맞다.
|
|
3423
|
+
|
|
3424
|
+
구조: **페이지 헤더 → `SSplitter`( `Before` = 목록 카드 · `After` = 상세 카드 )**
|
|
3425
|
+
|
|
3426
|
+
- **사이드(목록)를 `Before` 에 두고 `unit="px"` 로 폭을 고정한다.** 모델은 항상 첫 패널 크기이고, 사이드바처럼 폭이 유지돼야 하는 자리는 `px` 다. `limits` 로 목록이 읽을 수 없을 만큼 좁아지지 않게 한다 (§3-6).
|
|
3427
|
+
- **높이는 `contentHeight="fill"` 사슬로 넘긴다.** `SSplitter` 는 부모를 채우기만 하므로 블록 스택(`h-full min-h-0`) → `SSplitter`(`min-h-0 flex-1`) 로 남은 높이를 이어 준다 (§2-2·§3-6).
|
|
3428
|
+
- **두 패널은 각자 안에서 스크롤한다.** 목록 카드는 `h-full` 로 패널을 채우고 본문(마지막 자식)만 스크롤하게 한다 (§4-5 "섹션 본문이 자기 안에서 스크롤"). 끝에는 패널 자체 스크롤의 하단 여백 토큰을 둔다 (§2-2 "스크롤 영역의 하단 여백"). 상세 패널은 넘치면 패널이 스스로 스크롤한다 — `SScrollArea` 를 겹치지 않는다.
|
|
3429
|
+
- **패널 사이 간격** — 구분선 자리는 평소 칠해지지 않아 카드끼리 거의 붙는다. 양쪽 패널에 같은 안쪽 여백(`pr-sd-4` / `pl-sd-4`)을 주어 구분선 자리와 합친 폭이 블록 간격과 맞게 한다.
|
|
3430
|
+
- **목록은 §3-7-7 대로 고른다.** 계층이 있으면 `SExpansionList` + `SExpansionItem`(하위가 있는 항목) + `SListItem`(하위가 없는 항목) — 리스트는 루트에 하나다, 평평하면 `SList` + `SListItem clickable`. `STree` 는 체크박스로 여러 개를 고르는 다중 선택용이라 "하나를 골라 연다" 에 쓰지 않는다.
|
|
3431
|
+
- **선택은 페이지가 든다** — `selectedId` 스칼라 하나. URL 로 복원할 수 있어야 하는 값이다 (§3-7-7 "펼침 ≠ 선택").
|
|
3432
|
+
- **상세 폼은 고른 항목마다 새로 세운다** — 상세 컴포넌트에 `key={selectedId}` 를 준다. 그렇지 않으면 편집하던 값이 다음에 고른 항목으로 넘어간다.
|
|
3433
|
+
- **상세 카드의 저장·삭제는 카드 푸터다.** 화면의 일부만 확정하는 카드라 `footerSize="sm"` 이고, `footerLeft` 의 버튼도 `size="sm"` 이다 (§3-7-8). 목록 카드의 "추가" 같은 부가 액션은 헤더 `slot` 에 `size="sm"` 으로 둔다.
|
|
3434
|
+
|
|
3435
|
+
<!-- recipe:src/recipes/CategoryManagePage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
|
|
3436
|
+
```tsx
|
|
3437
|
+
import { useState } from 'react';
|
|
3438
|
+
import {
|
|
3439
|
+
SButton,
|
|
3440
|
+
SExpansionItem,
|
|
3441
|
+
SExpansionList,
|
|
3442
|
+
SKeyValueTable,
|
|
3443
|
+
SListItem,
|
|
3444
|
+
SModal,
|
|
3445
|
+
SSectionHeaderCard,
|
|
3446
|
+
SSplitter,
|
|
3447
|
+
type SKeyValueField,
|
|
3448
|
+
} from 'sellmate-design-system-react';
|
|
3449
|
+
import AppShell from './AppShell';
|
|
3450
|
+
import { removeCategory, saveCategory, useCategory, useCategoryTree } from './api';
|
|
3451
|
+
import { useNavigate } from './router';
|
|
3452
|
+
|
|
3453
|
+
const USE_OPTIONS = [
|
|
3454
|
+
{ label: '사용', value: 'Y' },
|
|
3455
|
+
{ label: '미사용', value: 'N' },
|
|
3456
|
+
];
|
|
3457
|
+
|
|
3458
|
+
const detailFields: SKeyValueField[][] = [
|
|
3459
|
+
[
|
|
3460
|
+
{ name: 'path', label: '경로', type: 'text' },
|
|
3461
|
+
{ name: 'code', label: '코드', type: 'text' },
|
|
3462
|
+
],
|
|
3463
|
+
[
|
|
3464
|
+
{ name: 'name', label: '이름', type: 'input', required: true },
|
|
3465
|
+
{ name: 'useYn', label: '사용 여부', type: 'radio', options: { options: USE_OPTIONS } },
|
|
3466
|
+
],
|
|
3467
|
+
[{ name: 'memo', label: '메모', type: 'textarea', tdColSpan: 3 }],
|
|
3468
|
+
];
|
|
3469
|
+
|
|
3470
|
+
type Values = Record<string, unknown>;
|
|
3471
|
+
|
|
3472
|
+
export default function CategoryManagePage() {
|
|
3473
|
+
const navigate = useNavigate();
|
|
3474
|
+
const tree = useCategoryTree();
|
|
3475
|
+
|
|
3476
|
+
// 선택은 앱이 든다 — 스칼라 하나라 상호배제가 구조적으로 보장되고, URL 로 복원할 수 있다.
|
|
3477
|
+
// 펼침은 SExpansionList 가 관리한다 (§3-7-7 "펼침 ≠ 선택")
|
|
3478
|
+
const [selectedId, setSelectedId] = useState('apparel-0');
|
|
3479
|
+
|
|
3480
|
+
const item = (c: { id: string; name: string; productCount: number }) => (
|
|
3481
|
+
<SListItem
|
|
3482
|
+
key={c.id}
|
|
3483
|
+
title={c.name}
|
|
3484
|
+
// 건수는 보조 텍스트로 — 숫자는 toLocaleString (§1-4)
|
|
3485
|
+
supportingText={c.productCount.toLocaleString()}
|
|
3486
|
+
selected={selectedId === c.id}
|
|
3487
|
+
onClick={() => setSelectedId(c.id)}
|
|
3488
|
+
/>
|
|
3489
|
+
);
|
|
3490
|
+
|
|
3491
|
+
return (
|
|
3492
|
+
// 두 패널이 각자 안에서 스크롤해야 하므로 fill (§2-2 "페이지 높이")
|
|
3493
|
+
<AppShell header={{ variant: 'bar', title: '카테고리 관리' }} contentHeight="fill">
|
|
3494
|
+
{/* SSplitter 는 부모를 채우기만 한다 — 블록 스택에서 min-h-0 flex-1 로 남은 높이를 넘겨 준다 (§3-6) */}
|
|
3495
|
+
<div className="flex h-full min-h-0 flex-col gap-sd-12">
|
|
3496
|
+
{/* 사이드가 기준인 화면이라 사이드를 Before 에 두고 폭을 px 로 고정한다.
|
|
3497
|
+
구분선 자리가 좁아 카드끼리 붙지 않게 양쪽 패널에 같은 안쪽 여백을 준다 —
|
|
3498
|
+
구분선 자리와 합쳐 블록 간격(gap-sd-12)이 되게 맞춘다 */}
|
|
3499
|
+
<SSplitter unit="px" defaultValue={280} limits={[240, 480]} className="min-h-0 flex-1">
|
|
3500
|
+
<SSplitter.Before className="pr-sd-4">
|
|
3501
|
+
{/* 목록 카드는 패널 높이를 채우고, 본문(마지막 자식)만 자기 안에서 스크롤한다 (§4-5).
|
|
3502
|
+
카드 안이 목록 하나뿐이라 padding="none" 으로 가장자리까지 채운다 */}
|
|
3503
|
+
<SSectionHeaderCard
|
|
3504
|
+
title="카테고리"
|
|
3505
|
+
marker
|
|
3506
|
+
padding="none"
|
|
3507
|
+
className="h-full [&>div:last-child]:min-h-0 [&>div:last-child]:flex-1 [&>div:last-child]:overflow-y-auto"
|
|
3508
|
+
// 목록에 항목을 더하는 부가 액션 — 헤더 slot 의 sm 버튼 (§4-5)
|
|
3509
|
+
slot={
|
|
3510
|
+
<SButton
|
|
3511
|
+
size="sm"
|
|
3512
|
+
color="neutral"
|
|
3513
|
+
outline
|
|
3514
|
+
icon="add"
|
|
3515
|
+
label="추가"
|
|
3516
|
+
onClick={() => navigate('/categories/new')}
|
|
3517
|
+
/>
|
|
3518
|
+
}
|
|
3519
|
+
>
|
|
3520
|
+
{/* 하위가 있는 대분류만 SExpansionItem, 전체·미분류는 SListItem (§3-7-7).
|
|
3521
|
+
리스트는 루트에 하나 — SListItem 의 clickable 은 리스트가 켜 주므로 selected 만 넘긴다.
|
|
3522
|
+
패널 자체 스크롤이라 끝에 scrollEnd 여백을 둔다 (§2-2 "스크롤 영역의 하단 여백") */}
|
|
3523
|
+
<SExpansionList className="pb-[var(--cmp-pageBody-padding-scrollEnd)]">
|
|
3524
|
+
{tree.map(top =>
|
|
3525
|
+
top.children ? (
|
|
3526
|
+
<SExpansionItem
|
|
3527
|
+
key={top.id}
|
|
3528
|
+
title={top.name}
|
|
3529
|
+
supportingText={top.productCount.toLocaleString()}
|
|
3530
|
+
defaultExpanded={top.id === 'apparel'}
|
|
3531
|
+
>
|
|
3532
|
+
{/* 펼친 항목 안에는 하위 항목을 바로 넣는다 — 리스트가 다음 단계로 다뤄
|
|
3533
|
+
clickable·depth·구분선·본문 여백이 따라온다 (§3-7-7) */}
|
|
3534
|
+
{top.children.map(item)}
|
|
3535
|
+
</SExpansionItem>
|
|
3536
|
+
) : (
|
|
3537
|
+
item(top)
|
|
3538
|
+
),
|
|
3539
|
+
)}
|
|
3540
|
+
</SExpansionList>
|
|
3541
|
+
</SSectionHeaderCard>
|
|
3542
|
+
</SSplitter.Before>
|
|
3543
|
+
|
|
3544
|
+
{/* 오른쪽 패널은 넘치면 패널이 스스로 스크롤한다 — SScrollArea 를 겹치지 않는다 (§3-6) */}
|
|
3545
|
+
<SSplitter.After className="pl-sd-4">
|
|
3546
|
+
{/* key 로 고른 항목마다 폼을 새로 세운다 — 바꿔 고르면 편집 중이던 값이 다음 항목으로 새지 않는다 */}
|
|
3547
|
+
<CategoryDetail
|
|
3548
|
+
key={selectedId}
|
|
3549
|
+
id={selectedId}
|
|
3550
|
+
onRemoved={() => setSelectedId('all')}
|
|
3551
|
+
/>
|
|
3552
|
+
</SSplitter.After>
|
|
3553
|
+
</SSplitter>
|
|
3554
|
+
</div>
|
|
3555
|
+
</AppShell>
|
|
3556
|
+
);
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3559
|
+
/** 오른쪽 패널 — 고른 카테고리 하나의 인라인 폼 */
|
|
3560
|
+
function CategoryDetail({ id, onRemoved }: { id: string; onRemoved: () => void }) {
|
|
3561
|
+
const category = useCategory(id);
|
|
3562
|
+
const [values, setValues] = useState<Values>(category ?? {});
|
|
3563
|
+
if (!category) return null;
|
|
3564
|
+
|
|
3565
|
+
const confirmDelete = () =>
|
|
3566
|
+
SModal.confirm({
|
|
3567
|
+
type: 'negative',
|
|
3568
|
+
modalTitle: '카테고리를 삭제할까요?',
|
|
3569
|
+
topMessage: ['소속 상품은 미분류로 옮겨집니다.'],
|
|
3570
|
+
mainButtonLabel: '삭제',
|
|
3571
|
+
}).onOk(async () => {
|
|
3572
|
+
await removeCategory(id);
|
|
3573
|
+
onRemoved();
|
|
3574
|
+
});
|
|
3575
|
+
|
|
3576
|
+
return (
|
|
3577
|
+
// 화면의 일부(분할 영역)만 확정하는 카드라 footerSize="sm", 슬롯 버튼도 sm (§3-7-8)
|
|
3578
|
+
<SSectionHeaderCard
|
|
3579
|
+
title={category.name}
|
|
3580
|
+
marker
|
|
3581
|
+
padding="none"
|
|
3582
|
+
footerSize="sm"
|
|
3583
|
+
footerLeft={<SButton color="danger" outline size="sm" label="삭제" onClick={confirmDelete} />}
|
|
3584
|
+
button={{ label: '저장', onClick: () => saveCategory(values) }}
|
|
3585
|
+
>
|
|
3586
|
+
{/* 표 아래에 카드 푸터가 오므로 radius="full" (§2-2) */}
|
|
3587
|
+
<SKeyValueTable
|
|
3588
|
+
fields={detailFields}
|
|
3589
|
+
values={values}
|
|
3590
|
+
onChange={({ values }) => setValues(values)}
|
|
3591
|
+
bordered={false}
|
|
3592
|
+
radius="full"
|
|
3593
|
+
/>
|
|
3594
|
+
</SSectionHeaderCard>
|
|
3595
|
+
);
|
|
3596
|
+
}
|
|
3597
|
+
```
|
|
3598
|
+
<!-- /recipe -->
|
|
3599
|
+
|
|
3600
|
+
### 4-10. 설정 페이지 — 탭 + 섹션 + 탭 단위 저장
|
|
3601
|
+
|
|
3602
|
+
구조: **페이지 헤더 → `STabs` → 섹션 카드들(`SSectionHeaderCard` + `SKeyValueTable`) → 하단 액션(되돌리기 · 저장)**
|
|
3603
|
+
|
|
3604
|
+
- **탭은 보는 관점을 바꾼다** — 고르면 아래 내용이 통째로 바뀌므로 폼 값이 아니다 (§3-7-2). 블록 스택 맨 위에 선다 (§2-0).
|
|
3605
|
+
- **저장은 탭 단위다.** 한 번에 보이는 것만 저장한다 — 보이지 않는 탭의 편집 값까지 함께 저장되면 무엇이 바뀌는지 확인할 수 없다.
|
|
3606
|
+
- 탭 본문을 컴포넌트로 빼고 `key={tab}` 로 탭마다 새로 세운다. 편집 값이 다른 탭으로 새지 않는다.
|
|
3607
|
+
- **저장하지 않은 값이 있으면 탭을 옮기기 전에 `SModal.confirm` 으로 묻는다.** 그냥 옮기면 편집 값이 말없이 사라진다.
|
|
3608
|
+
- **저장·되돌리기는 카드 푸터가 아니라 카드 밖 하단 줄이다.** 탭 전체를 확정하는 액션이라서다 (§3-7-8). 되돌리기(`neutral outline`)는 왼쪽 끝, 저장(`primary` 채움 하나)은 오른쪽 끝 (§3-5-1·§4-3).
|
|
3609
|
+
- **한 탭 안에서 즉시 반영과 저장 버튼을 섞지 않는다.** 스위치만 즉시 반영되고 나머지는 저장해야 반영되면, 어떤 값이 이미 적용됐는지 읽히지 않는다. 이 레시피의 스위치도 저장 때 함께 반영된다.
|
|
3610
|
+
- 섹션 하나는 카드 하나이고, 카드 안이 표 하나면 `padding="none"` + 표 `bordered={false} radius="useTop"` (§2-2). 섹션이 여럿이라 `marker` 를 켠다 (§4-5).
|
|
3611
|
+
- **고르면 이후 처리가 통째로 달라지는 소수 결정은 `SRadioCardGroup` 으로 자기 섹션을 갖는다** — 표의 한 행으로 줄이지 않는다 (§3-7-2). 카드 목록이라 섹션 여백은 기본값이다.
|
|
3612
|
+
- 단위가 붙는 숫자는 `SNumberInput` 의 `inputSuffix` 로 단다 — 라벨에 괄호로 붙이거나 `addonLabel` 로 앞에 세우지 않는다.
|
|
3613
|
+
- 섹션이 쌓여 창보다 길어질 수 있으므로 `contentHeight="auto"` + `scrollEndSpacing` 이다 (§2-2).
|
|
3614
|
+
|
|
3615
|
+
<!-- recipe:src/recipes/SettingsPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
|
|
3616
|
+
```tsx
|
|
3617
|
+
import { useState } from 'react';
|
|
3618
|
+
import {
|
|
3619
|
+
SButton,
|
|
3620
|
+
SKeyValueTable,
|
|
3621
|
+
SModal,
|
|
3622
|
+
SRadioCardGroup,
|
|
3623
|
+
SSectionHeaderCard,
|
|
3624
|
+
STabs,
|
|
3625
|
+
type SKeyValueField,
|
|
3626
|
+
} from 'sellmate-design-system-react';
|
|
3627
|
+
import AppShell from './AppShell';
|
|
3628
|
+
import { SETTINGS_TABS, saveSettings, useSettings } from './api';
|
|
3629
|
+
|
|
3630
|
+
type Values = Record<string, unknown>;
|
|
3631
|
+
|
|
3632
|
+
/** 탭마다 섹션 목록 — 섹션 하나가 카드 하나, 카드 안은 SKeyValueTable 하나다 */
|
|
3633
|
+
const SECTIONS: Record<string, { title: string; fields: SKeyValueField[][] }[]> = {
|
|
3634
|
+
basic: [
|
|
3635
|
+
{
|
|
3636
|
+
title: '사업자 정보',
|
|
3637
|
+
fields: [
|
|
3638
|
+
[
|
|
3639
|
+
{ name: 'storeName', label: '상호', type: 'input', required: true },
|
|
3640
|
+
{ name: 'bizNo', label: '사업자 번호', type: 'text' },
|
|
3641
|
+
],
|
|
3642
|
+
[
|
|
3643
|
+
{ name: 'manager', label: '담당자', type: 'input' },
|
|
3644
|
+
{ name: 'phone', label: '대표 번호', type: 'input' },
|
|
3645
|
+
],
|
|
3646
|
+
],
|
|
3647
|
+
},
|
|
3648
|
+
{
|
|
3649
|
+
title: '반품지',
|
|
3650
|
+
fields: [
|
|
3651
|
+
[{ name: 'returnAddress', label: '주소', type: 'input', options: { width: '100%' } }],
|
|
3652
|
+
],
|
|
3653
|
+
},
|
|
3654
|
+
],
|
|
3655
|
+
order: [
|
|
3656
|
+
{
|
|
3657
|
+
title: '주문 처리',
|
|
3658
|
+
fields: [
|
|
3659
|
+
[
|
|
3660
|
+
{ name: 'autoConfirm', label: '자동 주문 확정', type: 'switch' },
|
|
3661
|
+
{
|
|
3662
|
+
name: 'holdHours',
|
|
3663
|
+
label: '확정 보류 시간',
|
|
3664
|
+
type: 'number-input',
|
|
3665
|
+
options: { min: 1, max: 72, width: 'xs', inputSuffix: '시간' },
|
|
3666
|
+
},
|
|
3667
|
+
],
|
|
3668
|
+
[{ name: 'splitShipping', label: '부분 출고 허용', type: 'switch', tdColSpan: 3 }],
|
|
3669
|
+
],
|
|
3670
|
+
},
|
|
3671
|
+
],
|
|
3672
|
+
notice: [
|
|
3673
|
+
{
|
|
3674
|
+
title: '알림 받을 항목',
|
|
3675
|
+
fields: [
|
|
3676
|
+
[
|
|
3677
|
+
{ name: 'newOrder', label: '신규 주문', type: 'switch' },
|
|
3678
|
+
{ name: 'cancelRequest', label: '취소 요청', type: 'switch' },
|
|
3679
|
+
],
|
|
3680
|
+
[
|
|
3681
|
+
{ name: 'lowStock', label: '재고 부족', type: 'switch' },
|
|
3682
|
+
{
|
|
3683
|
+
name: 'stockThreshold',
|
|
3684
|
+
label: '재고 부족 기준',
|
|
3685
|
+
type: 'number-input',
|
|
3686
|
+
options: { min: 0, width: 'xs', inputSuffix: '개 이하' },
|
|
3687
|
+
},
|
|
3688
|
+
],
|
|
3689
|
+
[
|
|
3690
|
+
{
|
|
3691
|
+
name: 'channel',
|
|
3692
|
+
label: '받는 곳',
|
|
3693
|
+
type: 'radio',
|
|
3694
|
+
tdColSpan: 3,
|
|
3695
|
+
options: {
|
|
3696
|
+
options: [
|
|
3697
|
+
{ label: '카카오 알림톡', value: 'kakao' },
|
|
3698
|
+
{ label: '이메일', value: 'email' },
|
|
3699
|
+
],
|
|
3700
|
+
},
|
|
3701
|
+
},
|
|
3702
|
+
],
|
|
3703
|
+
],
|
|
3704
|
+
},
|
|
3705
|
+
],
|
|
3706
|
+
};
|
|
3707
|
+
|
|
3708
|
+
// 고르면 이후 주문 처리가 통째로 달라지는 소수 결정이라 설명 줄이 붙는 SRadioCardGroup (§3-7-2)
|
|
3709
|
+
const ASSIGN_OPTIONS = [
|
|
3710
|
+
{
|
|
3711
|
+
value: 'auto',
|
|
3712
|
+
label: '자동 배정',
|
|
3713
|
+
subtitle: '재고가 있는 창고 중 가장 가까운 곳으로 배정합니다.',
|
|
3714
|
+
},
|
|
3715
|
+
{
|
|
3716
|
+
value: 'priority',
|
|
3717
|
+
label: '우선순위 배정',
|
|
3718
|
+
subtitle: '창고 우선순위대로 재고가 있는 첫 창고에 배정합니다.',
|
|
3719
|
+
},
|
|
3720
|
+
{ value: 'manual', label: '수동 배정', subtitle: '담당자가 주문마다 창고를 고릅니다.' },
|
|
3721
|
+
];
|
|
3722
|
+
|
|
3723
|
+
export default function SettingsPage() {
|
|
3724
|
+
const [tab, setTab] = useState('basic');
|
|
3725
|
+
const [dirty, setDirty] = useState(false);
|
|
3726
|
+
|
|
3727
|
+
// 저장하지 않은 값이 있으면 탭을 옮기기 전에 묻는다 — 탭을 옮기면 그 탭의 편집 값이 사라진다
|
|
3728
|
+
const changeTab = (next: string) => {
|
|
3729
|
+
if (!dirty) return setTab(next);
|
|
3730
|
+
SModal.confirm({
|
|
3731
|
+
modalTitle: '저장하지 않은 변경 사항이 있습니다',
|
|
3732
|
+
topMessage: ['다른 탭으로 옮기면 이 탭에서 바꾼 값이 사라집니다.'],
|
|
3733
|
+
mainButtonLabel: '이동',
|
|
3734
|
+
}).onOk(() => {
|
|
3735
|
+
setDirty(false);
|
|
3736
|
+
setTab(next);
|
|
3737
|
+
});
|
|
3738
|
+
};
|
|
3739
|
+
|
|
3740
|
+
return (
|
|
3741
|
+
// 섹션이 쌓여 창보다 길어질 수 있어 페이지가 스크롤한다 — auto + scrollEndSpacing (§2-2)
|
|
3742
|
+
<AppShell header={{ variant: 'bar', title: '환경 설정' }} contentHeight="auto" scrollEndSpacing>
|
|
3743
|
+
<div className="flex flex-col gap-sd-12">
|
|
3744
|
+
{/* 탭은 아래 블록 전체를 바꾸므로 페이지 제목 바로 아래, 블록 스택의 맨 위에 선다 */}
|
|
3745
|
+
<STabs value={tab} tabs={SETTINGS_TABS} onValueChange={changeTab} />
|
|
3746
|
+
{/* key 로 탭마다 본문을 새로 세운다 — 편집 값이 다른 탭으로 새지 않는다 */}
|
|
3747
|
+
<SettingsTab key={tab} tab={tab} onDirtyChange={setDirty} />
|
|
3748
|
+
</div>
|
|
3749
|
+
</AppShell>
|
|
3750
|
+
);
|
|
3751
|
+
}
|
|
3752
|
+
|
|
3753
|
+
/** 탭 하나의 본문 — 탭 단위로 편집하고 탭 단위로 저장한다 */
|
|
3754
|
+
function SettingsTab({
|
|
3755
|
+
tab,
|
|
3756
|
+
onDirtyChange,
|
|
3757
|
+
}: {
|
|
3758
|
+
tab: string;
|
|
3759
|
+
onDirtyChange: (dirty: boolean) => void;
|
|
3760
|
+
}) {
|
|
3761
|
+
const saved = useSettings(tab);
|
|
3762
|
+
const [values, setValues] = useState<Values>(saved);
|
|
3763
|
+
|
|
3764
|
+
const change = (next: Values) => {
|
|
3765
|
+
setValues(next);
|
|
3766
|
+
onDirtyChange(true);
|
|
3767
|
+
};
|
|
3768
|
+
const reset = () => {
|
|
3769
|
+
setValues(saved);
|
|
3770
|
+
onDirtyChange(false);
|
|
3771
|
+
};
|
|
3772
|
+
const save = async () => {
|
|
3773
|
+
await saveSettings(tab, values);
|
|
3774
|
+
onDirtyChange(false);
|
|
3775
|
+
};
|
|
3776
|
+
|
|
3777
|
+
return (
|
|
3778
|
+
<>
|
|
3779
|
+
{/* 주문 배정 방식은 표의 한 행이 아니라 자기 섹션을 갖는다 — 카드 목록이라 기본 여백(16) 이다 (§2-2) */}
|
|
3780
|
+
{tab === 'order' && (
|
|
3781
|
+
<SSectionHeaderCard title="주문 배정 방식" marker>
|
|
3782
|
+
<SRadioCardGroup
|
|
3783
|
+
options={ASSIGN_OPTIONS}
|
|
3784
|
+
value={values.assignMode as string}
|
|
3785
|
+
onValueChange={v => change({ ...values, assignMode: v })}
|
|
3786
|
+
/>
|
|
3787
|
+
</SSectionHeaderCard>
|
|
3788
|
+
)}
|
|
3789
|
+
|
|
3790
|
+
{/* 섹션 카드가 여럿이면 marker 를 켠다 — 한 탭 안에서 전부 같게 간다 (§4-5) */}
|
|
3791
|
+
{SECTIONS[tab].map(section => (
|
|
3792
|
+
<SSectionHeaderCard key={section.title} title={section.title} marker padding="none">
|
|
3793
|
+
<SKeyValueTable
|
|
3794
|
+
fields={section.fields}
|
|
3795
|
+
values={values}
|
|
3796
|
+
onChange={({ values }) => change(values)}
|
|
3797
|
+
bordered={false}
|
|
3798
|
+
radius="useTop"
|
|
3799
|
+
/>
|
|
3800
|
+
</SSectionHeaderCard>
|
|
3801
|
+
))}
|
|
3802
|
+
|
|
3803
|
+
{/* 저장은 탭 전체를 확정하므로 카드 푸터가 아니라 카드 밖 하단 줄이다 (§3-7-8).
|
|
3804
|
+
되돌리기는 왼쪽 끝, 저장은 오른쪽 끝 (§4-3) */}
|
|
3805
|
+
<div className="flex items-center justify-between">
|
|
3806
|
+
<SButton color="neutral" outline label="되돌리기" onClick={reset} />
|
|
3807
|
+
<SButton label="저장" onClick={save} />
|
|
3808
|
+
</div>
|
|
3809
|
+
</>
|
|
3810
|
+
);
|
|
3811
|
+
}
|
|
3812
|
+
```
|
|
3813
|
+
<!-- /recipe -->
|
|
3814
|
+
|
|
3815
|
+
### 4-11. 단계형 — 스텝퍼 + 단계 본문
|
|
3816
|
+
|
|
3817
|
+
순서대로 거쳐야 하는 작업이다. 단계를 건너뛰거나 순서를 바꿔도 되는 입력이면 단계형이 아니라 §4-3 폼(섹션 나눔)이다.
|
|
3818
|
+
|
|
3819
|
+
**스텝퍼 방향은 작업의 모양이 정한다.**
|
|
3820
|
+
|
|
3821
|
+
| | 가로형 | 세로형 |
|
|
3822
|
+
| --- | --- | --- |
|
|
3823
|
+
| 작업 | 한 번에 끝까지 거치는 짧은 작업 (엑셀 일괄 등록) | 단계가 많아 그룹으로 묶이고, 여러 번에 걸쳐 채우는 설정 (초기 설정·연동) |
|
|
3824
|
+
| 단계 수 | 3~5개 | 그 이상, `group` 으로 묶는다 |
|
|
3825
|
+
| 이동 | 프레임 푸터의 이전·다음만 — 가로 스텝퍼는 기본으로 눌리지 않는다 | 스텝퍼를 눌러 끝낸 단계로 돌아간다. 앞으로는 끝낸 단계 바로 다음까지만 간다 (컴포넌트가 막는다) |
|
|
3826
|
+
| 저장 | 마지막 실행 단계에서 한 번 | 단계마다 — 저장한 단계가 완료로 남는다 |
|
|
3827
|
+
| 상태 | 지난 단계 = 완료 | 단계마다 완료·오류가 따로 남는다. 완료한 단계에도 오류가 남을 수 있다 (인증 만료 등) |
|
|
3828
|
+
|
|
3829
|
+
#### 가로형
|
|
3830
|
+
|
|
3831
|
+
구조: **페이지 헤더(뒤로가기 = 그만두기) → 프레임 `SCard` 하나 [스텝퍼 | `SDivider` | 단계 본문] + 카드 푸터(이전 · 다음)**
|
|
3832
|
+
|
|
3833
|
+
세로형과 같은 문법이다 — **스텝퍼와 단계 본문을 한 프레임에 담고, 단계 이동은 그 프레임의 푸터가 맡는다.** 다른 것은 스텝퍼가 위에 눕는다는 것뿐이다.
|
|
3834
|
+
|
|
3835
|
+
- **스텝퍼는 프레임 맨 위, 가운데다.** 카드든 모달이든 같다. 지난 단계는 `completed` 로 표시한다. 가로 스텝퍼는 기본으로 눌리지 않으므로(`clickable` 기본 꺼짐) 돌아가기는 푸터의 "이전" 이 맡는다. 스텝퍼가 현재 단계 이름을 말하므로 **단계 제목을 따로 두지 않는다.**
|
|
3836
|
+
- **단계 이동은 카드 푸터다** — `footerLeft`(이전) · `button`(다음)으로 넘긴다(§3-7-8). 하단 줄을 카드 안에 손으로 만들거나 카드 밖에 따로 세우지 않는다. 프레임이 화면의 주 액션을 쥐므로 `footerSize` 는 기본(`md`)이고, `footerLeft` 슬롯 버튼도 `size="md"` 를 명시한다(§3-5-2).
|
|
3837
|
+
- 오른쪽 버튼은 그 단계의 최상위 실행이라 `primary` 하나다(§3-5-1). 실행하는 단계에서는 라벨을 "다음" 대신 하는 일로 쓴다(`19개 등록`). 완료 단계는 다음 화면으로 가는 버튼(`상품 목록으로`)이다.
|
|
3838
|
+
- 첫 단계의 이전은 비활성, 완료 단계에는 되돌릴 것이 없어 이전을 두지 않는다. 그 자리(`footerLeft`)는 위계가 낮은 실행 액션 자리가 된다(§3-5-6) — 건너뛴 행이 있으면 "오류 행 내려받기"(`neutral outline`, `icon="download"`)를 둔다. 파일을 받는 실행이라 `STextLink`(이동)가 아니라 `SButton` 이다.
|
|
3839
|
+
- **화살표 아이콘은 이동 버튼에만 붙인다** — 이전은 `icon="arrowLeft"`, 다음은 `rightIcon="arrowRight"`. 실행 버튼(`19개 등록`)과 완료 단계 버튼에는 붙이지 않는다 — 화살표가 붙으면 되돌릴 수 없는 실행이 "다음 단계 보기" 처럼 가볍게 읽힌다.
|
|
3840
|
+
- 단계마다 바뀌는 `button` 을 변수로 만들 때는 `SCardProps['button']`(모달이면 `SActionModalProps['button']`)로 타입을 붙인다 — 붙이지 않으면 `rightIcon` 같은 아이콘 이름이 `string` 으로 넓어져 타입 에러가 난다.
|
|
3841
|
+
- **그만두기(취소)는 푸터에 두지 않는다** — 카드 푸터는 오른쪽 버튼이 하나라 취소·이전·다음을 함께 담을 수 없다. 페이지는 헤더의 `onBack` 이, 모달은 닫기(X)가 그 길이다.
|
|
3842
|
+
- **다음으로 가기 전에 그 단계를 검증한다.** `SForm` 으로 카드를 감싸고 다음 버튼을 `button={{ type: 'submit', … }}` 로 두면 규칙을 통과했을 때만 넘어간다 — 카드 푸터 버튼도 `SButton` props 를 그대로 받는다.
|
|
3843
|
+
- **단계들은 한 프레임을 공유하므로 여백은 가장 복합적인 단계에 맞춰 통일한다** (§2-2 "위자드·탭"). `SCard` 에는 `padding` prop 이 없으므로 직접 만든 영역의 규칙을 따른다 — 이 레시피는 1단계가 안내·카드 선택·표 세 종류라 스텝퍼 줄과 본문 모두 24 다. 단계마다 따로 판정하지 않는다.
|
|
3844
|
+
- **푸터는 고정하고 단계 본문만 스크롤한다.** 카드를 `flex h-full flex-col` 로 두고 본문을 `SScrollArea axis="vertical" overlayScrollbar` + `min-h-0 flex-1` 로 잇는다. `SCard` 는 기본이 `h-fit` 이라 이 사슬을 빠뜨리면 2단계처럼 내용이 긴 단계에서 카드가 자라 푸터가 화면 밖으로 밀린다 — 짧은 첫 단계에서는 드러나지 않아 놓치기 쉽다.
|
|
3845
|
+
- 검증 결과처럼 일부가 빠지는 단계는 **무엇이 빠지는지 먼저 말한다** — 표 위 `SCallout type="danger"` 에 건수와 처리 방법을 적는다.
|
|
3846
|
+
- **완료 단계는 결과 안내다** — 성공 아이콘(`SIcon`, `--sys-color-fg-success`) · 제목(`typo-heading-sm`) · 건수 설명(`typo-body-sm-default` + `text-fg-secondary`)을 본문 가운데에 세로로 쌓는다. 건너뛴 것이 있으면 그 건수도 다시 적는다. 프레임이 이미 카드라 **결과를 카드로 한 번 더 두르지 않는다**(§2-0). 세로 가운데에 놓으려면 본문 쌓기 상자에 `min-h-full`, 결과 블록에 `flex-1 justify-center` 를 준다 — `h-full` 은 기댈 높이가 없어 듣지 않는다.
|
|
3847
|
+
- **모달 안에서도 같은 구성이다.** `SActionModal` 본문 맨 위에 스텝퍼를 두고, 이전은 `footerLeft`, 다음은 `button` 으로 넘긴다 — 모달 푸터도 같은 API 다. 그만두기는 모달의 닫기(X)가 맡는다. 단계를 오가며 입력이 쌓이지만 `SActionModal` 은 기본이 바깥 클릭·ESC 로 닫히지 않으므로(§3-3-5) `persistent` 를 따로 주지 않는다.
|
|
3848
|
+
|
|
3849
|
+
<!-- recipe:src/recipes/ProductBulkUploadPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
|
|
3850
|
+
```tsx
|
|
3851
|
+
import { useState } from 'react';
|
|
3852
|
+
import {
|
|
3853
|
+
SButton,
|
|
3854
|
+
SCallout,
|
|
3855
|
+
SCard,
|
|
3856
|
+
SDivider,
|
|
3857
|
+
SForm,
|
|
3858
|
+
SIcon,
|
|
3859
|
+
SKeyValueTable,
|
|
3860
|
+
SRadioCardGroup,
|
|
3861
|
+
SScrollArea,
|
|
3862
|
+
SStepper,
|
|
3863
|
+
STable,
|
|
3864
|
+
STag,
|
|
3865
|
+
type SCardProps,
|
|
3866
|
+
type SKeyValueField,
|
|
3867
|
+
type STableColumn,
|
|
3868
|
+
} from 'sellmate-design-system-react';
|
|
3869
|
+
import AppShell from './AppShell';
|
|
3870
|
+
import { commitUpload, downloadErrorRows, validateUpload, type UploadRow } from './api';
|
|
3871
|
+
import { useNavigate } from './router';
|
|
3872
|
+
|
|
3873
|
+
const STEPS = [
|
|
3874
|
+
{ label: '파일 선택', value: 'file' },
|
|
3875
|
+
{ label: '검증 결과 확인', value: 'check' },
|
|
3876
|
+
{ label: '등록 완료', value: 'done' },
|
|
3877
|
+
];
|
|
3878
|
+
|
|
3879
|
+
const MODE_OPTIONS = [
|
|
3880
|
+
{ value: 'create', label: '신규 등록', subtitle: '파일의 모든 행을 새 상품으로 등록합니다.' },
|
|
3881
|
+
{ value: 'update', label: '기존 상품 수정', subtitle: '상품코드가 같은 상품의 값을 덮어씁니다.' },
|
|
3882
|
+
];
|
|
3883
|
+
|
|
3884
|
+
const fileFields: SKeyValueField[][] = [
|
|
3885
|
+
[
|
|
3886
|
+
{
|
|
3887
|
+
name: 'file',
|
|
3888
|
+
label: '엑셀 파일',
|
|
3889
|
+
type: 'file-picker',
|
|
3890
|
+
required: true,
|
|
3891
|
+
options: {
|
|
3892
|
+
accept: '.xlsx',
|
|
3893
|
+
placeholder: '.xlsx 파일을 올려 주세요',
|
|
3894
|
+
rules: [(v: unknown) => !!v || '파일을 선택해 주세요'],
|
|
3895
|
+
},
|
|
3896
|
+
},
|
|
3897
|
+
],
|
|
3898
|
+
];
|
|
3899
|
+
|
|
3900
|
+
const columns: STableColumn<UploadRow>[] = [
|
|
3901
|
+
// 행 번호는 엑셀의 위치를 가리키는 식별자다 → center
|
|
3902
|
+
{ name: 'row', label: '행', field: 'row', width: 64, align: 'center' },
|
|
3903
|
+
{ name: 'name', label: '상품명', field: 'name', width: 240 },
|
|
3904
|
+
{
|
|
3905
|
+
name: 'price',
|
|
3906
|
+
label: '판매가',
|
|
3907
|
+
field: 'price',
|
|
3908
|
+
width: 120,
|
|
3909
|
+
align: 'right',
|
|
3910
|
+
format: (v: unknown) => `${Number(v).toLocaleString()}원`,
|
|
3911
|
+
},
|
|
3912
|
+
{
|
|
3913
|
+
name: 'result',
|
|
3914
|
+
label: '결과',
|
|
3915
|
+
field: 'error',
|
|
3916
|
+
width: 240,
|
|
3917
|
+
contentType: 'control',
|
|
3918
|
+
render: row =>
|
|
3919
|
+
row.error ? (
|
|
3920
|
+
<STag size="sm" color="red" label={row.error} />
|
|
3921
|
+
) : (
|
|
3922
|
+
<STag size="sm" color="green" label="정상" />
|
|
3923
|
+
),
|
|
3924
|
+
},
|
|
3925
|
+
];
|
|
3926
|
+
|
|
3927
|
+
export default function ProductBulkUploadPage() {
|
|
3928
|
+
const navigate = useNavigate();
|
|
3929
|
+
const [step, setStep] = useState(0);
|
|
3930
|
+
const [values, setValues] = useState<Record<string, unknown>>({ mode: 'create' });
|
|
3931
|
+
const [rows, setRows] = useState<UploadRow[]>([]);
|
|
3932
|
+
const [registered, setRegistered] = useState(0);
|
|
3933
|
+
|
|
3934
|
+
const errorCount = rows.filter(r => r.error).length;
|
|
3935
|
+
// 지난 단계는 completed — 앞으로 건너뛰지 못하게 가로 스텝퍼는 clickable 이 기본으로 꺼져 있다
|
|
3936
|
+
const items = STEPS.map((s, i) => ({ ...s, completed: i < step }));
|
|
3937
|
+
|
|
3938
|
+
// 1단계 → 2단계: SForm 이 규칙을 통과시킨 뒤에만 불린다
|
|
3939
|
+
const validate = async () => {
|
|
3940
|
+
setRows(await validateUpload(values));
|
|
3941
|
+
setStep(1);
|
|
3942
|
+
};
|
|
3943
|
+
const commit = async () => {
|
|
3944
|
+
setRegistered(await commitUpload(rows));
|
|
3945
|
+
setStep(2);
|
|
3946
|
+
};
|
|
3947
|
+
|
|
3948
|
+
// 오른쪽 버튼은 단계마다 하는 일이 다르다 — 입력 단계는 SForm 검증을 거치는 submit,
|
|
3949
|
+
// 실행 단계는 라벨을 하는 일로 쓴다(`19개 등록`). 이 화면의 최상위 실행이라 primary 하나다 (§3-5-1).
|
|
3950
|
+
// 화살표는 단계를 옮기는 이동 버튼(이전·다음)에만 붙인다 — 실행 버튼에 붙이면 되돌릴 수 없는 실행이
|
|
3951
|
+
// "다음 단계 보기" 처럼 가볍게 읽힌다
|
|
3952
|
+
const button: SCardProps['button'] =
|
|
3953
|
+
step === 0
|
|
3954
|
+
? { label: '다음', rightIcon: 'arrowRight', type: 'submit' }
|
|
3955
|
+
: step === 1
|
|
3956
|
+
? {
|
|
3957
|
+
label: `${(rows.length - errorCount).toLocaleString()}개 등록`,
|
|
3958
|
+
type: 'button',
|
|
3959
|
+
disabled: rows.length === errorCount,
|
|
3960
|
+
onClick: commit,
|
|
3961
|
+
}
|
|
3962
|
+
: { label: '상품 목록으로', type: 'button', onClick: () => navigate('/products') };
|
|
3963
|
+
|
|
3964
|
+
return (
|
|
3965
|
+
// 그만두기는 헤더의 뒤로가기(onBack)가 맡는다 — 카드 푸터에는 이전·다음만 둔다.
|
|
3966
|
+
// 푸터를 늘 보이게 하려고 fill — 단계 본문만 카드 안에서 스크롤한다 (§2-2)
|
|
3967
|
+
<AppShell
|
|
3968
|
+
header={{ variant: 'bar', title: '상품 일괄 등록', onBack: () => navigate('/products') }}
|
|
3969
|
+
contentHeight="fill"
|
|
3970
|
+
>
|
|
3971
|
+
{/* 푸터의 다음 버튼(submit)이 폼 안에 들어야 하므로 SForm 이 카드를 감싼다 */}
|
|
3972
|
+
<SForm formClassName="h-full" onSubmit={validate}>
|
|
3973
|
+
{/* 스텝퍼와 단계 본문을 한 프레임(카드 하나)에 담는다 — 세로형(§4-11)과 같은 구성이다.
|
|
3974
|
+
단계 이동은 이 프레임 안에서 닫히는 액션이라 카드 푸터다 (§3-7-8). 프레임이 화면의 주 액션을
|
|
3975
|
+
쥐므로 footerSize 는 기본(md)이고, footerLeft 슬롯 버튼도 md 를 명시한다 (§3-5-2).
|
|
3976
|
+
첫 단계의 이전은 비활성, 완료 단계에는 되돌릴 것이 없어 이전 대신 오류 행 내려받기를 둔다 */}
|
|
3977
|
+
<SCard
|
|
3978
|
+
bordered
|
|
3979
|
+
className="flex h-full flex-col"
|
|
3980
|
+
footerLeft={
|
|
3981
|
+
step < 2 ? (
|
|
3982
|
+
<SButton
|
|
3983
|
+
type="button"
|
|
3984
|
+
color="neutral"
|
|
3985
|
+
outline
|
|
3986
|
+
size="md"
|
|
3987
|
+
label="이전"
|
|
3988
|
+
icon="arrowLeft"
|
|
3989
|
+
disabled={step === 0}
|
|
3990
|
+
onClick={() => setStep(0)}
|
|
3991
|
+
/>
|
|
3992
|
+
) : (
|
|
3993
|
+
// 완료 단계에는 되돌릴 것이 없다 — 왼쪽 끝은 위계가 낮은 실행 액션 자리다 (§3-5-6).
|
|
3994
|
+
// 건너뛴 행이 있으면 고쳐서 다시 올릴 수 있게 그 행을 내려받게 한다.
|
|
3995
|
+
// 파일을 받는 실행이라 링크(STextLink, 이동)가 아니라 버튼이다
|
|
3996
|
+
errorCount > 0 && (
|
|
3997
|
+
<SButton
|
|
3998
|
+
type="button"
|
|
3999
|
+
color="neutral"
|
|
4000
|
+
outline
|
|
4001
|
+
size="md"
|
|
4002
|
+
icon="download"
|
|
4003
|
+
label="오류 행 내려받기"
|
|
4004
|
+
onClick={() => downloadErrorRows(rows.filter(r => r.error))}
|
|
4005
|
+
/>
|
|
4006
|
+
)
|
|
4007
|
+
)
|
|
4008
|
+
}
|
|
4009
|
+
button={button}
|
|
4010
|
+
>
|
|
4011
|
+
{/* 스텝퍼가 현재 단계 이름을 말하므로 단계 제목을 따로 두지 않는다. 가로 스텝퍼는 프레임 가운데에 놓는다.
|
|
4012
|
+
단계들이 한 프레임을 공유하므로 여백은 가장 복합적인 단계에 맞춰 통일한다 (§2-2) —
|
|
4013
|
+
1단계가 안내 + 카드 선택 + 표로 세 종류라 프레임 전체가 24 다 */}
|
|
4014
|
+
<div className="flex justify-center p-sd-24">
|
|
4015
|
+
<SStepper items={items} value={STEPS[step].value} />
|
|
4016
|
+
</div>
|
|
4017
|
+
<SDivider />
|
|
4018
|
+
{/* 단계 본문만 스크롤한다 — 직접 만든 스크롤 영역은 SScrollArea (§1-1).
|
|
4019
|
+
스크롤 영역 자체를 flex 로 두지 않고 안쪽에 쌓기 상자를 둔다 — 여백·scrollEnd 여백도 안쪽에 (§2-2) */}
|
|
4020
|
+
<SScrollArea axis="vertical" overlayScrollbar className="min-h-0 flex-1">
|
|
4021
|
+
<div className="flex min-h-full flex-col gap-sd-12 p-sd-24 pb-[var(--cmp-pageBody-padding-scrollEnd)]">
|
|
4022
|
+
{step === 0 && (
|
|
4023
|
+
<>
|
|
4024
|
+
<SCallout
|
|
4025
|
+
message={[
|
|
4026
|
+
'양식의 첫 행(머리글)은 지우지 않습니다.',
|
|
4027
|
+
'한 번에 1,000행까지 올릴 수 있습니다.',
|
|
4028
|
+
]}
|
|
4029
|
+
/>
|
|
4030
|
+
<SRadioCardGroup
|
|
4031
|
+
options={MODE_OPTIONS}
|
|
4032
|
+
value={values.mode as string}
|
|
4033
|
+
onValueChange={v => setValues({ ...values, mode: v })}
|
|
4034
|
+
/>
|
|
4035
|
+
<SKeyValueTable
|
|
4036
|
+
fields={fileFields}
|
|
4037
|
+
values={values}
|
|
4038
|
+
onChange={({ values }) => setValues(values)}
|
|
4039
|
+
/>
|
|
4040
|
+
</>
|
|
4041
|
+
)}
|
|
4042
|
+
|
|
4043
|
+
{step === 1 && (
|
|
4044
|
+
<>
|
|
4045
|
+
{/* 오류가 있으면 무엇이 빠지는지 먼저 말한다 — 오류 행은 등록하지 않고 건너뛴다 */}
|
|
4046
|
+
{errorCount > 0 ? (
|
|
4047
|
+
<SCallout
|
|
4048
|
+
type="danger"
|
|
4049
|
+
message={[
|
|
4050
|
+
`전체 ${rows.length.toLocaleString()}행 중 ${errorCount.toLocaleString()}행에 오류가 있습니다.`,
|
|
4051
|
+
'오류 행은 등록하지 않고 건너뜁니다. 고쳐서 올리려면 이전 단계로 돌아갑니다.',
|
|
4052
|
+
]}
|
|
4053
|
+
/>
|
|
4054
|
+
) : (
|
|
4055
|
+
<SCallout
|
|
4056
|
+
message={[`전체 ${rows.length.toLocaleString()}행을 등록할 수 있습니다.`]}
|
|
4057
|
+
/>
|
|
4058
|
+
)}
|
|
4059
|
+
<STable<UploadRow> columns={columns} rows={rows} rowKey="id" />
|
|
4060
|
+
</>
|
|
4061
|
+
)}
|
|
4062
|
+
|
|
4063
|
+
{step === 2 && (
|
|
4064
|
+
// 결과 안내 — 아이콘·제목·설명을 본문 가운데에 세로로 쌓는다.
|
|
4065
|
+
// 프레임이 이미 카드라 카드를 한 겹 더 두르지 않는다 (§2-0 포함 규칙).
|
|
4066
|
+
// 무엇이 빠졌는지도 여기서 다시 말한다 — 건너뛴 행이 있으면 건수를 적는다
|
|
4067
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-sd-8 py-sd-48 text-center">
|
|
4068
|
+
<SIcon name="checkFill" size={40} color="var(--sys-color-fg-success)" />
|
|
4069
|
+
<h2 className="typo-heading-sm m-0">상품 등록을 마쳤습니다</h2>
|
|
4070
|
+
<p className="typo-body-sm-default m-0 text-fg-secondary">
|
|
4071
|
+
상품 {registered.toLocaleString()}개를 등록했습니다.
|
|
4072
|
+
{errorCount > 0 && ` 오류 ${errorCount.toLocaleString()}행은 건너뛰었습니다.`}
|
|
4073
|
+
</p>
|
|
4074
|
+
</div>
|
|
4075
|
+
)}
|
|
4076
|
+
</div>
|
|
4077
|
+
</SScrollArea>
|
|
4078
|
+
</SCard>
|
|
4079
|
+
</SForm>
|
|
4080
|
+
</AppShell>
|
|
4081
|
+
);
|
|
4082
|
+
}
|
|
4083
|
+
```
|
|
4084
|
+
<!-- /recipe -->
|
|
4085
|
+
|
|
4086
|
+
#### 세로형
|
|
4087
|
+
|
|
4088
|
+
구조: **페이지 헤더 → 프레임 `SCard` 하나 [스텝퍼 열 | `SDivider vertical` | 단계 본문] + 카드 푸터(이전 · 저장하고 다음)**
|
|
4089
|
+
|
|
4090
|
+
- **스텝퍼와 단계 본문을 한 프레임(카드 하나)에 담는다.** 둘이 한 작업으로 읽힌다. 단계가 길어 스텝퍼를 블록 스택 맨 위에 눕힐 수 없으므로 자기 열을 갖고, 열 폭은 화면 고유 치수로 고정한다(§1-2). 열 사이 경계는 항상 보이는 선이라 `SDivider vertical` 이다(§3-6).
|
|
4091
|
+
- **단계 저장은 프레임 안에서 닫히는 액션이라 카드 푸터다** — `SCard` 의 `footerLeft`(이전) · `button`(저장하고 다음, 마지막은 저장하고 마치기)으로 넘긴다(§3-7-8). 하단 줄을 `SDivider`·패딩으로 손으로 만들지 않는다 — 바탕·여백 토큰이 빠진다. 프레임이 화면의 주 액션을 쥐므로 `footerSize` 는 기본(`md`)이고, `footerLeft` 슬롯 버튼도 `size="md"` 를 명시한다(§3-5-2). 화살표는 가로형과 같은 규칙이다 — 이동 버튼인 이전에만 `icon="arrowLeft"` 를 붙이고, 저장을 실행하는 "저장하고 다음" 에는 붙이지 않는다.
|
|
4092
|
+
- **푸터는 고정하고 두 열만 각자 스크롤한다.** 카드를 `flex h-full flex-col` 로 두고 그 안의 열 그리드를 `min-h-0 flex-1` 로 이은 뒤, 열마다 `SScrollArea axis="vertical" overlayScrollbar` 를 둔다 — `div` 에 `overflow-y-auto` 를 직접 주지 않는다(§1-1). `overlayScrollbar` 는 막대를 스크롤하는 동안에만 띄워 폭을 차지하지 않으므로, 좁은 스텝퍼 열이 스크롤바만큼 깎이지 않는다. 열 전체를 푸터와 함께 스크롤시키면 단계가 길어질 때 저장 버튼이 화면 밖으로 밀린다. **스크롤 영역 자체를 `flex flex-col` 로 두지 않는다** — 자식(표)이 열 높이에 맞춰 눌려(`flex-shrink`) 넘치지 않고 잘린다. `SScrollArea` 안에 세로 쌓기 상자를 한 겹 두고, 여백과 패널 스크롤의 하단 여백(`pb-[var(--cmp-pageBody-padding-scrollEnd)]`, §2-2)은 그 안쪽 상자에 준다 — `overlayScrollbar` 에서는 `SScrollArea` 에 준 패딩이 바깥 상자에 걸려 콘텐츠가 그 여백 아래로 흘러들지 않는다.
|
|
4093
|
+
- **단계 본문 제목은 섹션 제목 프리셋(`typo-heading-sm`)을 직접 쓴다** — 카드 안에 카드를 겹칠 수 없어서다(§3-7-8). 곁에 그룹 이름을 `text-fg-secondary` 로 두어 스텝퍼의 그룹과 이어 읽히게 한다. 본문 영역은 직접 만든 영역이라 여백은 §2-2 판정을 따른다.
|
|
4094
|
+
- **저장하면 그 단계를 완료로 남기고 오류를 지운 뒤 다음 단계로 넘어간다.** 저장 버튼이 카드로 올라가므로 편집 값은 페이지가 든다 — 단계를 옮기면 편집 값을 비우고 그 단계의 저장된 값을 다시 보인다.
|
|
4095
|
+
- **처음 열 단계는 아직 끝내지 않은 첫 단계다.** 이미 끝낸 단계부터 다시 보여주지 않는다.
|
|
4096
|
+
- **스텝퍼 열이 스크롤돼도 활성 단계는 스텝퍼가 보이는 곳으로 옮긴다** — 처음 그릴 때와 단계가 바뀔 때, `showItemTooltip()` 으로 오류 단계를 짚을 때 모두 그렇다. 직접 `scrollIntoView` 를 부르지 않는다. 스텝퍼 폭도 부모보다 넓어지지 않으므로(스크롤바가 폭을 차지하는 일반 스크롤 영역에서도 가로 스크롤이 생기지 않는다), 열 폭을 스텝퍼 폭보다 넓게 잡거나 `overflow-x-hidden` 으로 가리지 않는다.
|
|
4097
|
+
- **완료·오류는 단계마다 앱이 든다.** 지난 단계라고 완료가 아니다 — 서버가 아는 상태를 `completed`·`error` 로 넘긴다.
|
|
4098
|
+
- **마치기 전에 남은 오류를 짚는다.** 확인이 필요한 단계가 남았으면 마치지 않고 `ref.showItemTooltip()` 으로 첫 오류 단계에 툴팁을 띄운다. 문구는 스텝퍼가 정한다 — 직접 만든 토스트·모달로 대신하지 않는다.
|
|
4099
|
+
|
|
4100
|
+
<!-- recipe:src/recipes/StoreSetupPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
|
|
4101
|
+
```tsx
|
|
4102
|
+
import { useRef, useState } from 'react';
|
|
4103
|
+
import {
|
|
4104
|
+
SButton,
|
|
4105
|
+
SCard,
|
|
4106
|
+
SDivider,
|
|
4107
|
+
SKeyValueTable,
|
|
4108
|
+
SScrollArea,
|
|
4109
|
+
SStepper,
|
|
4110
|
+
type SKeyValueField,
|
|
4111
|
+
type SStepperHandle,
|
|
4112
|
+
} from 'sellmate-design-system-react';
|
|
4113
|
+
import AppShell from './AppShell';
|
|
4114
|
+
import { SETUP_STEPS, saveSetupStep, useSetupStatus, useSetupStep } from './api';
|
|
4115
|
+
import { useNavigate } from './router';
|
|
4116
|
+
|
|
4117
|
+
/** 단계마다 입력 표 하나 — 단계 이름이 본문 제목이 되므로 필드만 정한다 */
|
|
4118
|
+
const FIELDS: Record<string, SKeyValueField[][]> = {
|
|
4119
|
+
'biz': [
|
|
4120
|
+
[
|
|
4121
|
+
{ name: 'storeName', label: '상호', type: 'input', required: true },
|
|
4122
|
+
{ name: 'bizNo', label: '사업자 번호', type: 'input', required: true },
|
|
4123
|
+
],
|
|
4124
|
+
],
|
|
4125
|
+
'warehouse': [
|
|
4126
|
+
[
|
|
4127
|
+
{ name: 'warehouseName', label: '창고 이름', type: 'input', required: true },
|
|
4128
|
+
{ name: 'address', label: '주소', type: 'input' },
|
|
4129
|
+
],
|
|
4130
|
+
],
|
|
4131
|
+
'smartstore': [
|
|
4132
|
+
[
|
|
4133
|
+
{ name: 'sellerId', label: '판매자 ID', type: 'input', required: true },
|
|
4134
|
+
{ name: 'apiKey', label: 'API 키', type: 'input', required: true },
|
|
4135
|
+
],
|
|
4136
|
+
],
|
|
4137
|
+
'coupang': [
|
|
4138
|
+
[
|
|
4139
|
+
{ name: 'vendorId', label: '업체 코드', type: 'input', required: true },
|
|
4140
|
+
{ name: 'accessKey', label: 'Access Key', type: 'input', required: true },
|
|
4141
|
+
],
|
|
4142
|
+
],
|
|
4143
|
+
// 항목이 많은 단계 — 본문 열이 창보다 길어져 그 열 안에서 스크롤한다 (푸터는 고정)
|
|
4144
|
+
'order': [
|
|
4145
|
+
[
|
|
4146
|
+
{ name: 'autoConfirm', label: '자동 주문 확정', type: 'switch' },
|
|
4147
|
+
{
|
|
4148
|
+
name: 'holdHours',
|
|
4149
|
+
label: '확정 보류 시간',
|
|
4150
|
+
type: 'number-input',
|
|
4151
|
+
options: { min: 1, max: 72, width: 'xs', inputSuffix: '시간' },
|
|
4152
|
+
},
|
|
4153
|
+
],
|
|
4154
|
+
[
|
|
4155
|
+
{ name: 'splitShipping', label: '부분 출고 허용', type: 'switch' },
|
|
4156
|
+
{ name: 'combineShipping', label: '합포장 허용', type: 'switch' },
|
|
4157
|
+
],
|
|
4158
|
+
[
|
|
4159
|
+
{
|
|
4160
|
+
name: 'cutoffTime',
|
|
4161
|
+
label: '당일 출고 마감',
|
|
4162
|
+
type: 'select',
|
|
4163
|
+
options: {
|
|
4164
|
+
valueAsPrimitive: true,
|
|
4165
|
+
options: ['12:00', '14:00', '16:00', '18:00'].map(t => ({ label: t, value: t })),
|
|
4166
|
+
},
|
|
4167
|
+
},
|
|
4168
|
+
{
|
|
4169
|
+
name: 'courier',
|
|
4170
|
+
label: '기본 택배사',
|
|
4171
|
+
type: 'select',
|
|
4172
|
+
options: {
|
|
4173
|
+
valueAsPrimitive: true,
|
|
4174
|
+
options: [
|
|
4175
|
+
{ label: 'CJ대한통운', value: 'cj' },
|
|
4176
|
+
{ label: '롯데택배', value: 'lotte' },
|
|
4177
|
+
{ label: '한진택배', value: 'hanjin' },
|
|
4178
|
+
],
|
|
4179
|
+
},
|
|
4180
|
+
},
|
|
4181
|
+
],
|
|
4182
|
+
[
|
|
4183
|
+
{
|
|
4184
|
+
name: 'invoicePrint',
|
|
4185
|
+
label: '송장 출력',
|
|
4186
|
+
type: 'radio',
|
|
4187
|
+
tdColSpan: 3,
|
|
4188
|
+
options: {
|
|
4189
|
+
options: [
|
|
4190
|
+
{ label: '주문 확정 시 자동', value: 'auto' },
|
|
4191
|
+
{ label: '담당자가 직접', value: 'manual' },
|
|
4192
|
+
],
|
|
4193
|
+
},
|
|
4194
|
+
},
|
|
4195
|
+
],
|
|
4196
|
+
[
|
|
4197
|
+
{ name: 'giftWrap', label: '선물 포장 옵션', type: 'switch' },
|
|
4198
|
+
{
|
|
4199
|
+
name: 'giftWrapFee',
|
|
4200
|
+
label: '선물 포장 비용',
|
|
4201
|
+
type: 'number-input',
|
|
4202
|
+
options: { min: 0, width: 'sm', inputSuffix: '원' },
|
|
4203
|
+
},
|
|
4204
|
+
],
|
|
4205
|
+
[
|
|
4206
|
+
{
|
|
4207
|
+
name: 'returnDays',
|
|
4208
|
+
label: '반품 접수 기간',
|
|
4209
|
+
type: 'number-input',
|
|
4210
|
+
options: { min: 1, max: 30, width: 'xs', inputSuffix: '일' },
|
|
4211
|
+
},
|
|
4212
|
+
{ name: 'exchangeAllowed', label: '교환 허용', type: 'switch' },
|
|
4213
|
+
],
|
|
4214
|
+
[
|
|
4215
|
+
{
|
|
4216
|
+
name: 'returnFee',
|
|
4217
|
+
label: '반품 배송비',
|
|
4218
|
+
type: 'number-input',
|
|
4219
|
+
options: { min: 0, width: 'sm', inputSuffix: '원' },
|
|
4220
|
+
},
|
|
4221
|
+
{
|
|
4222
|
+
name: 'exchangeFee',
|
|
4223
|
+
label: '교환 배송비',
|
|
4224
|
+
type: 'number-input',
|
|
4225
|
+
options: { min: 0, width: 'sm', inputSuffix: '원' },
|
|
4226
|
+
},
|
|
4227
|
+
],
|
|
4228
|
+
[
|
|
4229
|
+
{
|
|
4230
|
+
name: 'packingMemo',
|
|
4231
|
+
label: '포장 안내',
|
|
4232
|
+
type: 'textarea',
|
|
4233
|
+
tdColSpan: 3,
|
|
4234
|
+
options: { placeholder: '출고 담당자에게 보일 포장 안내를 적어 주세요.' },
|
|
4235
|
+
},
|
|
4236
|
+
],
|
|
4237
|
+
[
|
|
4238
|
+
{
|
|
4239
|
+
name: 'shippingMemo',
|
|
4240
|
+
label: '배송 메모 기본값',
|
|
4241
|
+
type: 'textarea',
|
|
4242
|
+
tdColSpan: 3,
|
|
4243
|
+
options: { placeholder: '송장에 인쇄할 배송 메모를 적어 주세요.' },
|
|
4244
|
+
},
|
|
4245
|
+
],
|
|
4246
|
+
],
|
|
4247
|
+
'notice': [[{ name: 'newOrder', label: '신규 주문 알림', type: 'switch' }]],
|
|
4248
|
+
'account': [
|
|
4249
|
+
[
|
|
4250
|
+
{ name: 'managerName', label: '담당자 이름', type: 'input', required: true },
|
|
4251
|
+
{ name: 'managerEmail', label: '이메일', type: 'input', required: true },
|
|
4252
|
+
],
|
|
4253
|
+
],
|
|
4254
|
+
'11st': [
|
|
4255
|
+
[
|
|
4256
|
+
{ name: 'sellerId', label: '판매자 ID', type: 'input', required: true },
|
|
4257
|
+
{ name: 'apiKey', label: 'API 키', type: 'input', required: true },
|
|
4258
|
+
],
|
|
4259
|
+
],
|
|
4260
|
+
'gmarket': [
|
|
4261
|
+
[
|
|
4262
|
+
{ name: 'sellerId', label: '판매자 ID', type: 'input', required: true },
|
|
4263
|
+
{ name: 'apiKey', label: 'API 키', type: 'input', required: true },
|
|
4264
|
+
],
|
|
4265
|
+
],
|
|
4266
|
+
'own': [[{ name: 'mallUrl', label: '쇼핑몰 주소', type: 'input', options: { width: '100%' } }]],
|
|
4267
|
+
'category': [[{ name: 'useMapping', label: '판매처 카테고리 매핑', type: 'switch' }]],
|
|
4268
|
+
'option': [
|
|
4269
|
+
[
|
|
4270
|
+
{
|
|
4271
|
+
name: 'defaultTemplate',
|
|
4272
|
+
label: '기본 옵션 템플릿',
|
|
4273
|
+
type: 'select',
|
|
4274
|
+
options: {
|
|
4275
|
+
valueAsPrimitive: true,
|
|
4276
|
+
options: [
|
|
4277
|
+
{ label: '사이즈', value: 'size' },
|
|
4278
|
+
{ label: '색상', value: 'color' },
|
|
4279
|
+
{ label: '사이즈 + 색상', value: 'size-color' },
|
|
4280
|
+
],
|
|
4281
|
+
},
|
|
4282
|
+
},
|
|
4283
|
+
],
|
|
4284
|
+
],
|
|
4285
|
+
'settlement': [
|
|
4286
|
+
[
|
|
4287
|
+
{
|
|
4288
|
+
name: 'settleDay',
|
|
4289
|
+
label: '정산일',
|
|
4290
|
+
type: 'number-input',
|
|
4291
|
+
options: { min: 1, max: 28, width: 'xs', inputSuffix: '일' },
|
|
4292
|
+
},
|
|
4293
|
+
],
|
|
4294
|
+
],
|
|
4295
|
+
'permission': [[{ name: 'approvalRequired', label: '주문 취소 승인 필요', type: 'switch' }]],
|
|
4296
|
+
};
|
|
4297
|
+
|
|
4298
|
+
type Values = Record<string, unknown>;
|
|
4299
|
+
|
|
4300
|
+
export default function StoreSetupPage() {
|
|
4301
|
+
const navigate = useNavigate();
|
|
4302
|
+
const status = useSetupStatus();
|
|
4303
|
+
const stepperRef = useRef<SStepperHandle>(null);
|
|
4304
|
+
// 처음에는 아직 끝내지 않은 첫 단계를 연다
|
|
4305
|
+
const [step, setStep] = useState(
|
|
4306
|
+
() => SETUP_STEPS.find(s => !status.completed.includes(s.value))?.value ?? SETUP_STEPS[0].value,
|
|
4307
|
+
);
|
|
4308
|
+
const [completed, setCompleted] = useState(status.completed);
|
|
4309
|
+
const [errors, setErrors] = useState(status.error);
|
|
4310
|
+
|
|
4311
|
+
// 편집 중인 값은 저장 전까지만 산다 — 단계를 옮기면 비우고 그 단계의 저장된 값을 다시 보인다
|
|
4312
|
+
const saved = useSetupStep(step);
|
|
4313
|
+
const [draft, setDraft] = useState<Values | null>(null);
|
|
4314
|
+
const values = draft ?? saved;
|
|
4315
|
+
const goTo = (next: string) => {
|
|
4316
|
+
setDraft(null);
|
|
4317
|
+
setStep(next);
|
|
4318
|
+
};
|
|
4319
|
+
|
|
4320
|
+
const index = SETUP_STEPS.findIndex(s => s.value === step);
|
|
4321
|
+
const current = SETUP_STEPS[index];
|
|
4322
|
+
const isLast = index === SETUP_STEPS.length - 1;
|
|
4323
|
+
// 완료·오류는 단계마다 따로 남는다 — 완료한 단계에도 오류가 남을 수 있다(인증 만료 등)
|
|
4324
|
+
const items = SETUP_STEPS.map(s => ({
|
|
4325
|
+
...s,
|
|
4326
|
+
completed: completed.includes(s.value),
|
|
4327
|
+
error: errors.includes(s.value),
|
|
4328
|
+
}));
|
|
4329
|
+
|
|
4330
|
+
// 단계를 저장하면 완료로 남기고 다음 단계로 넘어간다. 마지막 단계에서는 남은 오류부터 짚는다
|
|
4331
|
+
const save = async () => {
|
|
4332
|
+
await saveSetupStep(step, values);
|
|
4333
|
+
setCompleted(current => (current.includes(step) ? current : [...current, step]));
|
|
4334
|
+
const restErrors = errors.filter(e => e !== step);
|
|
4335
|
+
setErrors(restErrors);
|
|
4336
|
+
if (!isLast) return goTo(SETUP_STEPS[index + 1].value);
|
|
4337
|
+
setDraft(null);
|
|
4338
|
+
// 확인이 필요한 단계가 남았으면 첫 오류 단계에 툴팁을 띄운다 — 문구는 스텝퍼가 정한다
|
|
4339
|
+
if (restErrors.length) return stepperRef.current?.showItemTooltip();
|
|
4340
|
+
navigate('/');
|
|
4341
|
+
};
|
|
4342
|
+
|
|
4343
|
+
return (
|
|
4344
|
+
// 스텝퍼 열과 단계 본문이 각자 안에서 스크롤해야 하므로 fill (§2-2)
|
|
4345
|
+
<AppShell header={{ variant: 'bar', title: '초기 설정' }} contentHeight="fill">
|
|
4346
|
+
{/* 스텝퍼와 단계 본문을 한 프레임(카드 하나)에 담는다 — 둘이 한 작업으로 읽힌다.
|
|
4347
|
+
단계 저장은 이 프레임 안에서 닫히는 액션이라 카드 푸터다 (§3-7-8). 프레임이 화면의 주 액션을
|
|
4348
|
+
쥐므로 footerSize 는 기본(md)이고, footerLeft 슬롯 버튼도 md 를 명시한다 (§3-5-2) */}
|
|
4349
|
+
<SCard
|
|
4350
|
+
bordered
|
|
4351
|
+
className="flex h-full flex-col"
|
|
4352
|
+
footerLeft={
|
|
4353
|
+
<SButton
|
|
4354
|
+
color="neutral"
|
|
4355
|
+
outline
|
|
4356
|
+
size="md"
|
|
4357
|
+
label="이전"
|
|
4358
|
+
icon="arrowLeft"
|
|
4359
|
+
disabled={index === 0}
|
|
4360
|
+
onClick={() => goTo(SETUP_STEPS[index - 1].value)}
|
|
4361
|
+
/>
|
|
4362
|
+
}
|
|
4363
|
+
button={{ label: isLast ? '저장하고 마치기' : '저장하고 다음', onClick: save }}
|
|
4364
|
+
>
|
|
4365
|
+
{/* 스텝퍼 열 | 구분선 | 단계 본문. 푸터는 고정하고 두 열만 각자 스크롤한다 — min-h-0 flex-1 로 높이를 잇는다.
|
|
4366
|
+
스텝퍼 열 폭은 화면 고유 치수다 (§1-2) */}
|
|
4367
|
+
<div className="grid min-h-0 flex-1 grid-cols-[240px_auto_1fr]">
|
|
4368
|
+
{/* 세로형은 기본으로 눌린다 — 끝낸 단계로 돌아가 고칠 수 있고, 앞으로는 끝낸 단계 바로 다음까지만 간다 */}
|
|
4369
|
+
{/* 직접 만든 스크롤 영역은 SScrollArea 다 (§1-1). overlayScrollbar 는 막대를 스크롤하는 동안에만
|
|
4370
|
+
영역 위에 띄워 폭을 차지하지 않는다 — 좁은 열의 폭이 스크롤바만큼 깎이지 않는다.
|
|
4371
|
+
단계가 많으면 이 열도 자기 안에서 스크롤한다 — 패널 자체 스크롤이라 끝에 scrollEnd 여백을 둔다 (§2-2).
|
|
4372
|
+
overlay 모드에서는 패딩이 바깥 상자에 걸리므로 여백은 안쪽 상자에 준다 */}
|
|
4373
|
+
<SScrollArea axis="vertical" overlayScrollbar className="min-h-0">
|
|
4374
|
+
<div className="pb-[var(--cmp-pageBody-padding-scrollEnd)]">
|
|
4375
|
+
<SStepper ref={stepperRef} vertical items={items} value={step} onValueChange={goTo} />
|
|
4376
|
+
</div>
|
|
4377
|
+
</SScrollArea>
|
|
4378
|
+
{/* 항상 보이는 세로 경계선 — 길이는 줄 높이를 채운다 (§3-6) */}
|
|
4379
|
+
<SDivider vertical />
|
|
4380
|
+
{/* 스크롤 영역(SScrollArea)과 세로 쌓기 상자를 나눈다 — 스크롤 상자를 flex 로 두면 표가 열 높이에 맞춰
|
|
4381
|
+
눌려(flex-shrink) 넘치지 않고 잘린다. 패널 자체 스크롤이라 끝에 scrollEnd 여백을 둔다 (§2-2) */}
|
|
4382
|
+
<SScrollArea axis="vertical" overlayScrollbar className="min-h-0">
|
|
4383
|
+
{/* 자체 면을 가진 덩어리가 표 하나뿐이라 직접 만든 영역의 여백은 16 이다 (§2-2) */}
|
|
4384
|
+
<div className="flex flex-col gap-sd-12 p-sd-16 pb-[var(--cmp-pageBody-padding-scrollEnd)]">
|
|
4385
|
+
{/* 프레임 안의 단계 제목 — 카드 안에 카드를 겹칠 수 없어 섹션 제목 프리셋을 직접 쓴다 (§2-1).
|
|
4386
|
+
그룹 이름을 곁에 두어 스텝퍼의 그룹과 이어 읽힌다 */}
|
|
4387
|
+
<div className="flex items-center gap-sd-8">
|
|
4388
|
+
<h2 className="typo-heading-sm m-0">{current.label}</h2>
|
|
4389
|
+
<span className="typo-body-sm-default text-fg-secondary">{current.group}</span>
|
|
4390
|
+
</div>
|
|
4391
|
+
<SKeyValueTable
|
|
4392
|
+
fields={FIELDS[step]}
|
|
4393
|
+
values={values}
|
|
4394
|
+
onChange={({ values }) => setDraft(values)}
|
|
4395
|
+
/>
|
|
4396
|
+
</div>
|
|
4397
|
+
</SScrollArea>
|
|
4398
|
+
</div>
|
|
4399
|
+
</SCard>
|
|
4400
|
+
</AppShell>
|
|
4401
|
+
);
|
|
4402
|
+
}
|
|
4403
|
+
```
|
|
4404
|
+
<!-- /recipe -->
|
|
4405
|
+
|
|
4406
|
+
### 4-12. 상담 화면 — 목록 · 대화 · 템플릿 세 영역
|
|
4407
|
+
|
|
4408
|
+
구조: **페이지 헤더 → [상담 목록 | 대화(메시지 스크롤 + 입력창) | 답변 템플릿]** — 화면을 통째로 쓰는 콘솔
|
|
4409
|
+
|
|
4410
|
+
- **페이지 여백을 끄고 세 영역이 화면 끝까지 찬다** — `AppShell` 에 `padding="none"`. 영역은 카드가 아니라 높이를 잇는 세로 영역(`flex h-full min-h-0 flex-col`)이다. 카드로 감싸면 화면 끝에 붙은 테두리가 겹쳐 그려지고, 세 영역 중 일부만 카드면 문법이 갈린다.
|
|
4411
|
+
- **영역 사이 경계는 상담사가 끌어 폭을 바꾼다 — `SSplitter`** (§3-6). 패널이 둘뿐이라 `[목록 | (대화 | 템플릿)]` 으로 중첩한다. 목록은 사이드라 `unit="px"`(기본·`limits` 는 화면 고유 치수), 대화·템플릿은 크기 모델이 첫 패널(대화)이라 비율로 나눈다.
|
|
4412
|
+
- **경계는 구분선 자리의 띠로 보인다.** 스플리터는 패널 사이에 구분선 자리(토큰 폭)를 비워 두고, 그 선은 끌 수 있는 자리를 알릴 때(hover·포커스)만 칠한다. 그대로 두면 빈 띠에서 머리 줄 가로선·선택 행 바탕이 끊겨 틈처럼 보인다. **`SSplitter` 바탕을 `bg-bg-neutralLight` 로 깔고 각 패널을 `bg-bg-frame` 으로 덮어** 그 띠만 옅은 면으로 남긴다 — 그 띠가 늘 보이는 영역 경계가 된다. 패널에 `border-*` 를 따로 긋거나 스플리터 구분선 색·두께를 바꾸지 않는다.
|
|
4413
|
+
- **세 영역의 머리 줄은 같은 높이다** — 그래야 아래 가로 구분선(`SDivider`)이 세 영역에서 한 줄로 이어진다. 높이는 화면 고유 치수로 한 곳에 두고 세 영역이 같이 쓴다. 머리 줄의 글자는 프리셋이다 — 이름·영역 제목은 `typo-heading-sm`, 문의 유형 같은 보조는 `typo-body-sm-default` + `text-fg-secondary` (§2-1). 임의 크기·굵기를 주지 않는다.
|
|
4414
|
+
- **상담 목록 머리 줄은 상태별 탭이다** — 보는 관점을 바꾸는 것이라 `STabs`(§3-7-2), 영역 안의 탭이라 밑줄형(`sub`), 건수는 `badge`. 목록 항목으로 탭을 흉내 내지 않는다(§1-1).
|
|
4415
|
+
- **영역이 좁아져 탭이 넘치면 탭 줄만 가로로 스크롤한다** — 탭 줄을 `SScrollArea axis="horizontal" overlayScrollbar` 로 감싸고, 영역에 `min-w-0` 을 준다. 그러지 않으면 넘친 폭이 영역 전체를 밀어내 패널에 가로 스크롤이 생긴다. 목록은 스크롤하지 않고 `SListItem` 의 보조 텍스트가 말줄임된다.
|
|
4416
|
+
- **스크롤 영역은 머리 줄 높이 전체로 늘린다(`self-stretch`)** — 안쪽은 `flex h-full items-center` 로 탭을 세로 가운데에 둔다. overlay 막대는 스크롤 영역 바닥에 뜨므로, 탭 높이로만 잡으면 막대가 탭 글자를 가린다.
|
|
4417
|
+
- 탭을 가운데에 두려면 안쪽을 `mx-auto w-fit` 으로 감싼다 — `justify-center` 로 두면 넘칠 때 앞쪽 탭이 왼쪽 밖으로 잘려 스크롤로도 닿지 않는다. overlay 라 좌우 여백은 안쪽 상자에 준다.
|
|
4418
|
+
- **세 영역은 각자 안에서 스크롤한다.** `contentHeight="fill"` 에 영역 그리드를 `h-full min-h-0` 으로 잇고, 영역마다 머리 줄 아래를 `SScrollArea axis="vertical" overlayScrollbar` + `min-h-0 flex-1` 로 채운다 (§1-1). 대화 영역은 그 아래에 입력창을 고정한다.
|
|
4419
|
+
- **여백은 영역마다 다르다** (§2-2 상담 콘솔 예). 목록·템플릿은 같은 항목의 반복이라 목록 컴포넌트가 자기 여백을 갖고, 머리 줄도 목록 항목의 좌우 여백(`--cmp-listItem-paddingX`)에 맞춘다. 대화는 말풍선·파일·시스템 안내가 섞여 24 다 — 머리 줄·스크롤 안쪽 상자가 24 를 쓴다. 입력 영역은 `SDivider` 로 대화와 나누고, 좌우는 메시지와 끝을 맞춰 24, 위아래는 16 이다.
|
|
4420
|
+
- **대화는 최신이 바닥이다.** 쌓기 상자에 `min-h-full justify-end` 로 메시지가 적어도 바닥에 붙이고, 처음 열 때와 메시지가 늘 때 스크롤 영역(`SScrollArea` 의 ref)을 맨 아래로 내린다. 입력창은 스크롤 밖 바닥에 고정한다.
|
|
4421
|
+
- **메시지 한 건 = 한 사람이 이어 말한 묶음이다.** 같은 사람이 같은 시각에 이어 보낸 글·파일은 `SChatMessage` 하나로 묶어 `messages`·`attachments` 로 넘긴다 — 말풍선마다 컴포넌트를 따로 세우지 않는다. 파일은 `attachments` 로만 넘긴다 (`messages` 로 넘기면 칸이 두 겹이 된다).
|
|
4422
|
+
- **줄 사이 간격은 토큰이 정한다** — 메시지 묶음 사이는 `--cmp-chatMessage-group-message-gap`, 시스템 안내(`SChatSystemMessage`) 앞뒤는 그보다 넓은 `--cmp-chatMessage-group-system-gap`. 숫자로 적지 않는다.
|
|
4423
|
+
- **날짜가 바뀌는 자리에는 날짜 구분선을 끼운다** — 가로 `SDivider` 두 개 사이에 날짜(`typo-body-sm-default` + `text-fg-tertiary`). 일어난 사실이 아니라 흐름의 경계라 시스템 안내 알약(`SChatSystemMessage`)을 쓰지 않는다. **날짜는 `2026년 10월 6일 화요일`** 로 쓴다 — 말풍선 시각과 같은 대화 예외다(§1-4). `toLocaleDateString()` 은 로케일에 따라 결과가 바뀌므로 쓰지 않고 연·월·일·요일을 직접 이어 붙인다. 가로 구분선은 기본이 전체 폭이라 한 줄에 둘을 놓을 때는 `className="w-auto flex-1"` 로 남은 폭을 나눠 갖게 한다. 앞뒤 간격은 시스템 안내와 같은 토큰이다. 날짜가 다르면 같은 시각이어도 한 묶음으로 합치지 않는다.
|
|
4424
|
+
- 대화의 시각은 `오후 5:52` 로 쓴다 (§1-4 대화 예외). 읽음 표시는 보낸 메시지에만, 템플릿으로 보낸 글은 `variant="template"` 로 면을 나눈다.
|
|
4425
|
+
- **입력 중인 글은 페이지가 상담마다 든다.** 상담을 옮겨도 쓰던 글이 남고, 템플릿 패널이 그 글에 넣을 수 있다. 템플릿은 누르면 **입력창에 넣는다** — 바로 보내지 않고 상담사가 고쳐 보낸다. 보내기 전 첨부는 입력창 위에 `SChatAttachedFile` 로 쌓는다.
|
|
4426
|
+
- **상담 종료는 `SChatInput` 의 `footerAction` 이다** — 전송과 층이 다른 동작이라 입력창 아래 보조 슬롯(`STextLink`)에 두고, 실행 전에 `SModal.confirm` 으로 묻는다. 종료된 상담은 입력창을 `disabled` 로 잠그고 종료 링크를 뺀다.
|
|
4427
|
+
- 목록의 상태는 `STag` 다 — 색 점(`SBadge`)만으로 찍지 않는다 (§3-1). 템플릿 분류는 `SExpansionList` 루트 하나에 분류(`SExpansionItem`) 안 템플릿(`SListItem`)을 바로 넣는다 (§3-7-7).
|
|
4428
|
+
|
|
4429
|
+
<!-- recipe:src/recipes/ConsultConsolePage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
|
|
4430
|
+
```tsx
|
|
4431
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4432
|
+
import {
|
|
4433
|
+
SChatAttachedFile,
|
|
4434
|
+
SChatFile,
|
|
4435
|
+
SChatInput,
|
|
4436
|
+
SChatMessage,
|
|
4437
|
+
SChatSystemMessage,
|
|
4438
|
+
SDivider,
|
|
4439
|
+
SExpansionItem,
|
|
4440
|
+
SExpansionList,
|
|
4441
|
+
SList,
|
|
4442
|
+
SListItem,
|
|
4443
|
+
SModal,
|
|
4444
|
+
SScrollArea,
|
|
4445
|
+
SSplitter,
|
|
4446
|
+
STabs,
|
|
4447
|
+
STag,
|
|
4448
|
+
STextLink,
|
|
4449
|
+
STooltip,
|
|
4450
|
+
} from 'sellmate-design-system-react';
|
|
4451
|
+
import AppShell from './AppShell';
|
|
4452
|
+
import {
|
|
4453
|
+
CONSULTS,
|
|
4454
|
+
REPLY_TEMPLATES,
|
|
4455
|
+
closeConsult,
|
|
4456
|
+
sendChatMessage,
|
|
4457
|
+
useConsultMessages,
|
|
4458
|
+
type ChatEntry,
|
|
4459
|
+
type Consult,
|
|
4460
|
+
} from './api';
|
|
4461
|
+
|
|
4462
|
+
const STATUS_TAG: Record<Consult['status'], { color: 'orange' | 'blue' | 'grey'; label: string }> =
|
|
4463
|
+
{
|
|
4464
|
+
waiting: { color: 'orange', label: '대기' },
|
|
4465
|
+
active: { color: 'blue', label: '상담 중' },
|
|
4466
|
+
closed: { color: 'grey', label: '종료' },
|
|
4467
|
+
};
|
|
4468
|
+
|
|
4469
|
+
const TEMPLATE_BODIES = new Set(REPLY_TEMPLATES.flatMap(g => g.items.map(t => t.body)));
|
|
4470
|
+
|
|
4471
|
+
type Message = Extract<ChatEntry, { kind: 'message' }>;
|
|
4472
|
+
/**
|
|
4473
|
+
* 화면에 그리는 단위 — 같은 사람이 같은 날 같은 시각에 이어 말한 메시지는 SChatMessage 하나로 묶고,
|
|
4474
|
+
* 날짜가 바뀌는 자리에는 날짜 구분선을 끼운다
|
|
4475
|
+
*/
|
|
4476
|
+
type Row =
|
|
4477
|
+
| { kind: 'date'; id: string; date: string }
|
|
4478
|
+
| { kind: 'system'; id: string; text: string }
|
|
4479
|
+
| { kind: 'group'; id: string; items: Message[] };
|
|
4480
|
+
|
|
4481
|
+
function groupEntries(entries: ChatEntry[]): Row[] {
|
|
4482
|
+
const rows: Row[] = [];
|
|
4483
|
+
let lastDate = '';
|
|
4484
|
+
for (const entry of entries) {
|
|
4485
|
+
if (entry.date !== lastDate) {
|
|
4486
|
+
rows.push({ kind: 'date', id: `d-${entry.date}`, date: entry.date });
|
|
4487
|
+
lastDate = entry.date;
|
|
4488
|
+
}
|
|
4489
|
+
const last = rows[rows.length - 1];
|
|
4490
|
+
if (entry.kind === 'system') rows.push(entry);
|
|
4491
|
+
else if (
|
|
4492
|
+
last?.kind === 'group' &&
|
|
4493
|
+
last.items[0].direction === entry.direction &&
|
|
4494
|
+
last.items[0].time === entry.time
|
|
4495
|
+
)
|
|
4496
|
+
last.items.push(entry);
|
|
4497
|
+
else rows.push({ kind: 'group', id: entry.id, items: [entry] });
|
|
4498
|
+
}
|
|
4499
|
+
return rows;
|
|
4500
|
+
}
|
|
4501
|
+
|
|
4502
|
+
const WEEKDAYS = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'];
|
|
4503
|
+
|
|
4504
|
+
/**
|
|
4505
|
+
* 대화의 날짜 구분선 표기 — `2026-10-06` → `2026년 10월 6일 화요일`.
|
|
4506
|
+
* 대화 흐름을 짚는 표시라 말하듯 읽히는 표기를 쓴다 (§1-4 대화 예외).
|
|
4507
|
+
* toLocaleDateString 은 쓰지 않는다 — 로케일에 따라 결과가 바뀐다 (§1-4)
|
|
4508
|
+
*/
|
|
4509
|
+
function formatChatDate(date: string) {
|
|
4510
|
+
const [y, m, d] = date.split('-').map(Number);
|
|
4511
|
+
const weekday = WEEKDAYS[new Date(y, m - 1, d).getDay()];
|
|
4512
|
+
return `${y}년 ${m}월 ${d}일 ${weekday}`;
|
|
4513
|
+
}
|
|
4514
|
+
|
|
4515
|
+
/** 줄 사이 간격은 토큰이 정한다 — 시스템 안내·날짜 구분선 앞뒤는 메시지 묶음 사이보다 넓게 벌린다 */
|
|
4516
|
+
const gapClass = (prev: Row | undefined, row: Row) =>
|
|
4517
|
+
!prev
|
|
4518
|
+
? ''
|
|
4519
|
+
: prev.kind !== 'group' || row.kind !== 'group'
|
|
4520
|
+
? 'mt-[var(--cmp-chatMessage-group-system-gap)]'
|
|
4521
|
+
: 'mt-[var(--cmp-chatMessage-group-message-gap)]';
|
|
4522
|
+
|
|
4523
|
+
export default function ConsultConsolePage() {
|
|
4524
|
+
const [consultId, setConsultId] = useState(CONSULTS[0].id);
|
|
4525
|
+
const consult = CONSULTS.find(c => c.id === consultId)!;
|
|
4526
|
+
// 입력 중인 글은 페이지가 상담마다 든다 — 상담을 옮겨도 쓰던 글이 남고, 템플릿 패널이 넣을 수 있다
|
|
4527
|
+
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
|
4528
|
+
const draft = drafts[consultId] ?? '';
|
|
4529
|
+
const setDraft = (text: string) => setDrafts(current => ({ ...current, [consultId]: text }));
|
|
4530
|
+
|
|
4531
|
+
return (
|
|
4532
|
+
// 상담 도구는 화면을 통째로 쓰는 콘솔이다 — 페이지 여백을 끄고(padding="none") 세 영역이 화면 끝까지 찬다.
|
|
4533
|
+
// 세 영역이 각자 안에서 스크롤해야 하므로 fill (§2-2)
|
|
4534
|
+
<AppShell header={{ variant: 'bar', title: '상담' }} contentHeight="fill" padding="none">
|
|
4535
|
+
{/* 목록 | 대화 | 템플릿 — 상담사가 경계를 끌어 영역 폭을 바꾼다(SSplitter, §3-6).
|
|
4536
|
+
SSplitter 는 패널이 둘이라 [목록 | (대화 | 템플릿)] 으로 중첩한다.
|
|
4537
|
+
목록은 사이드라 폭을 px 로 유지하고, 대화·템플릿은 첫 패널(대화) 기준 비율로 나눈다 */}
|
|
4538
|
+
<SSplitter unit="px" defaultValue={324} limits={[240, 480]} className="bg-bg-neutralLight">
|
|
4539
|
+
{/* 패널 사이에는 구분선 자리(토큰 폭)가 띠로 남는다 — 스플리터 바탕을 옅은 회색으로 깔고 패널은
|
|
4540
|
+
흰 면으로 덮어, 그 띠가 영역 경계로 늘 보이게 한다. 끌 수 있다는 표시(파란 선)는 hover 때 그 위에 드러난다 */}
|
|
4541
|
+
<SSplitter.Before className="bg-bg-frame">
|
|
4542
|
+
<ConsultList value={consultId} onChange={setConsultId} />
|
|
4543
|
+
</SSplitter.Before>
|
|
4544
|
+
<SSplitter.After>
|
|
4545
|
+
<SSplitter defaultValue={72} limits={[50, 85]} className="bg-bg-neutralLight">
|
|
4546
|
+
<SSplitter.Before className="bg-bg-frame">
|
|
4547
|
+
{/* key 로 상담마다 대화를 새로 세운다 — 첨부가 다른 상담으로 새지 않는다 */}
|
|
4548
|
+
<Conversation
|
|
4549
|
+
key={consultId}
|
|
4550
|
+
consult={consult}
|
|
4551
|
+
draft={draft}
|
|
4552
|
+
onDraftChange={setDraft}
|
|
4553
|
+
/>
|
|
4554
|
+
</SSplitter.Before>
|
|
4555
|
+
<SSplitter.After className="bg-bg-frame">
|
|
4556
|
+
{/* 템플릿을 누르면 입력창에 넣는다 — 바로 보내지 않고 상담사가 고쳐 보낼 수 있게 한다 */}
|
|
4557
|
+
<TemplatePanel onPick={body => setDraft(draft ? `${draft}\n${body}` : body)} />
|
|
4558
|
+
</SSplitter.After>
|
|
4559
|
+
</SSplitter>
|
|
4560
|
+
</SSplitter.After>
|
|
4561
|
+
</SSplitter>
|
|
4562
|
+
</AppShell>
|
|
4563
|
+
);
|
|
4564
|
+
}
|
|
4565
|
+
|
|
4566
|
+
/** 영역 하나 — 스플리터 패널의 높이를 이어 받아 [머리 줄 | 스크롤 영역] 으로 세로로 나눈다 */
|
|
4567
|
+
// min-w-0 — 안쪽이 넓어져도 영역 폭을 밀어내지 않는다 (넘치는 것은 그 자리에서 스크롤·말줄임한다)
|
|
4568
|
+
const PANEL = 'flex h-full min-h-0 min-w-0 flex-col';
|
|
4569
|
+
/**
|
|
4570
|
+
* 세 영역의 머리 줄. 높이를 하나로 맞춰야 아래 가로 구분선이 세 영역에서 한 줄로 이어진다 —
|
|
4571
|
+
* 화면 고유 치수라 임의 값이다 (§1-2). 좌우 여백은 영역마다 그 아래 내용의 여백을 따른다
|
|
4572
|
+
*/
|
|
4573
|
+
const PANEL_HEADER = 'flex h-[56px] shrink-0 items-center gap-sd-8';
|
|
4574
|
+
|
|
4575
|
+
const STATUS_TABS: { label: string; value: 'all' | Consult['status'] }[] = [
|
|
4576
|
+
{ label: '전체', value: 'all' },
|
|
4577
|
+
{ label: '대기', value: 'waiting' },
|
|
4578
|
+
{ label: '상담 중', value: 'active' },
|
|
4579
|
+
{ label: '종료', value: 'closed' },
|
|
4580
|
+
];
|
|
4581
|
+
|
|
4582
|
+
/** 왼쪽 — 상담 목록. 같은 항목의 반복이라 목록이 자기 여백을 갖는다 (§2-2 상담 콘솔 예) */
|
|
4583
|
+
function ConsultList({ value, onChange }: { value: string; onChange: (id: string) => void }) {
|
|
4584
|
+
// 상태별로 나눠 보는 것은 보는 관점을 바꾸는 것이라 STabs 다 (§3-7-2) — 목록 항목으로 탭을 흉내 내지 않는다
|
|
4585
|
+
const [status, setStatus] = useState<string>('all');
|
|
4586
|
+
const visible = status === 'all' ? CONSULTS : CONSULTS.filter(c => c.status === status);
|
|
4587
|
+
// 탭마다 건수를 배지로 단다 — 대기 건수가 눈에 띄어야 상담사가 먼저 집는다
|
|
4588
|
+
const tabs = STATUS_TABS.map(t => ({
|
|
4589
|
+
...t,
|
|
4590
|
+
badge: (t.value === 'all' ? CONSULTS : CONSULTS.filter(c => c.status === t.value)).length,
|
|
4591
|
+
}));
|
|
4592
|
+
|
|
4593
|
+
return (
|
|
4594
|
+
<section className={PANEL}>
|
|
4595
|
+
{/* 탭은 목록 스크롤 밖에 고정. 영역 안의 탭이라 밑줄형(sub).
|
|
4596
|
+
영역이 좁아져 탭이 넘치면 탭 줄만 가로로 스크롤한다 — 넘친 폭이 영역 전체를 밀어내지 않게
|
|
4597
|
+
스크롤 영역에 min-w-0. 안쪽은 mx-auto w-fit — 들어가면 가운데, 넘치면 왼쪽부터 (justify-center 로
|
|
4598
|
+
두면 넘칠 때 앞쪽 탭이 왼쪽 밖으로 잘려 스크롤로도 닿지 않는다). overlay 라 패딩은 안쪽에 준다 */}
|
|
4599
|
+
<div className={PANEL_HEADER}>
|
|
4600
|
+
{/* 스크롤 영역을 머리 줄 높이 전체로 늘리고(self-stretch) 탭은 그 안에서 세로 가운데에 둔다 —
|
|
4601
|
+
overlay 막대는 스크롤 영역 바닥에 뜨므로, 탭 높이로만 잡으면 막대가 탭 글자를 가린다 */}
|
|
4602
|
+
<SScrollArea axis="horizontal" overlayScrollbar className="min-w-0 flex-1 self-stretch">
|
|
4603
|
+
<div className="mx-auto flex h-full w-fit items-center px-(--cmp-listItem-paddingX)">
|
|
4604
|
+
<STabs sub value={status} tabs={tabs} onValueChange={setStatus} />
|
|
4605
|
+
</div>
|
|
4606
|
+
</SScrollArea>
|
|
4607
|
+
</div>
|
|
4608
|
+
<SDivider />
|
|
4609
|
+
{/* 직접 만든 스크롤 영역은 SScrollArea (§1-1) — 폭을 차지하지 않도록 overlay */}
|
|
4610
|
+
<SScrollArea axis="vertical" overlayScrollbar className="min-h-0 flex-1">
|
|
4611
|
+
<SList className="pb-[var(--cmp-pageBody-padding-scrollEnd)]">
|
|
4612
|
+
{visible.map(c => (
|
|
4613
|
+
<SListItem
|
|
4614
|
+
key={c.id}
|
|
4615
|
+
title={c.customer}
|
|
4616
|
+
supportingText={c.lastMessage}
|
|
4617
|
+
supportingTextPosition="bottom"
|
|
4618
|
+
// 상태는 STag — 색 점(SBadge)만으로 찍지 않는다 (§3-1)
|
|
4619
|
+
trailing={
|
|
4620
|
+
<STag
|
|
4621
|
+
size="sm"
|
|
4622
|
+
color={STATUS_TAG[c.status].color}
|
|
4623
|
+
label={STATUS_TAG[c.status].label}
|
|
4624
|
+
/>
|
|
4625
|
+
}
|
|
4626
|
+
clickable
|
|
4627
|
+
selected={c.id === value}
|
|
4628
|
+
onClick={() => onChange(c.id)}
|
|
4629
|
+
/>
|
|
4630
|
+
))}
|
|
4631
|
+
</SList>
|
|
4632
|
+
</SScrollArea>
|
|
4633
|
+
</section>
|
|
4634
|
+
);
|
|
4635
|
+
}
|
|
4636
|
+
|
|
4637
|
+
/** 가운데 — 대화. 말풍선·파일·시스템 안내가 섞이는 영역이라 여백이 24 다 (§2-2 상담 콘솔 예) */
|
|
4638
|
+
function Conversation({
|
|
4639
|
+
consult,
|
|
4640
|
+
draft,
|
|
4641
|
+
onDraftChange,
|
|
4642
|
+
}: {
|
|
4643
|
+
consult: Consult;
|
|
4644
|
+
draft: string;
|
|
4645
|
+
onDraftChange: (text: string) => void;
|
|
4646
|
+
}) {
|
|
4647
|
+
const initial = useConsultMessages(consult.id);
|
|
4648
|
+
const [entries, setEntries] = useState<ChatEntry[]>(initial);
|
|
4649
|
+
const [attached, setAttached] = useState<string[]>([]);
|
|
4650
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
4651
|
+
const closed = consult.status === 'closed';
|
|
4652
|
+
|
|
4653
|
+
// 대화는 최신이 바닥이다 — 처음 열 때와 메시지가 늘 때 맨 아래로 내린다
|
|
4654
|
+
useEffect(() => {
|
|
4655
|
+
const el = scrollRef.current;
|
|
4656
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
4657
|
+
}, [entries]);
|
|
4658
|
+
|
|
4659
|
+
const send = async (text: string) => {
|
|
4660
|
+
await sendChatMessage(consult.id, text);
|
|
4661
|
+
const date = '2026-10-06'; // 소비 앱에서는 서버가 준 날짜를 YYYY-MM-DD 로
|
|
4662
|
+
const time = '오후 5:55'; // 소비 앱에서는 서버가 준 시각을 `오후 h:mm` 으로 적는다 (§1-4 대화 예외)
|
|
4663
|
+
setEntries(current => [
|
|
4664
|
+
...current,
|
|
4665
|
+
...(text
|
|
4666
|
+
? [
|
|
4667
|
+
{
|
|
4668
|
+
kind: 'message' as const,
|
|
4669
|
+
id: `n${current.length}`,
|
|
4670
|
+
direction: 'outgoing' as const,
|
|
4671
|
+
date,
|
|
4672
|
+
time,
|
|
4673
|
+
text,
|
|
4674
|
+
// 템플릿 그대로 보낸 글은 template 면으로 — 무엇으로 보냈는지를 면이 말한다
|
|
4675
|
+
variant: TEMPLATE_BODIES.has(text) ? ('template' as const) : undefined,
|
|
4676
|
+
},
|
|
4677
|
+
]
|
|
4678
|
+
: []),
|
|
4679
|
+
...attached.map((name, i) => ({
|
|
4680
|
+
kind: 'message' as const,
|
|
4681
|
+
id: `f${current.length}-${i}`,
|
|
4682
|
+
direction: 'outgoing' as const,
|
|
4683
|
+
date,
|
|
4684
|
+
time,
|
|
4685
|
+
file: { name, size: 204800 },
|
|
4686
|
+
})),
|
|
4687
|
+
]);
|
|
4688
|
+
onDraftChange('');
|
|
4689
|
+
setAttached([]);
|
|
4690
|
+
};
|
|
4691
|
+
|
|
4692
|
+
const confirmClose = () =>
|
|
4693
|
+
SModal.confirm({
|
|
4694
|
+
modalTitle: '상담을 종료할까요?',
|
|
4695
|
+
topMessage: ['종료하면 고객에게 종료 안내가 전송됩니다.'],
|
|
4696
|
+
mainButtonLabel: '종료',
|
|
4697
|
+
}).onOk(() => closeConsult(consult.id));
|
|
4698
|
+
|
|
4699
|
+
const rows = groupEntries(entries);
|
|
4700
|
+
|
|
4701
|
+
return (
|
|
4702
|
+
// 대화 영역은 [머리 줄 | 대화 스크롤 | 입력창] 세로 세 칸 — 입력창은 바닥에 고정
|
|
4703
|
+
<section className={PANEL}>
|
|
4704
|
+
<div className={`${PANEL_HEADER} justify-between px-sd-24`}>
|
|
4705
|
+
{/* 누구와의 어떤 상담인지 — 이름은 섹션 제목 프리셋, 문의 유형은 보조 설명 (§2-1) */}
|
|
4706
|
+
<div className="flex min-w-0 flex-col">
|
|
4707
|
+
<span className="typo-heading-sm truncate">{consult.customer}</span>
|
|
4708
|
+
<span className="typo-body-sm-default truncate text-fg-secondary">{consult.topic}</span>
|
|
4709
|
+
</div>
|
|
4710
|
+
<STag
|
|
4711
|
+
size="sm"
|
|
4712
|
+
color={STATUS_TAG[consult.status].color}
|
|
4713
|
+
label={STATUS_TAG[consult.status].label}
|
|
4714
|
+
/>
|
|
4715
|
+
</div>
|
|
4716
|
+
<SDivider />
|
|
4717
|
+
<SScrollArea ref={scrollRef} axis="vertical" overlayScrollbar className="min-h-0 flex-1">
|
|
4718
|
+
{/* 메시지가 적으면 바닥에 붙인다(justify-end) — 대화는 아래에서 위로 쌓여 읽힌다 */}
|
|
4719
|
+
<div className="flex min-h-full flex-col justify-end p-sd-24 pb-[var(--cmp-pageBody-padding-scrollEnd)]">
|
|
4720
|
+
{rows.map((row, i) => (
|
|
4721
|
+
<div key={row.id} className={gapClass(rows[i - 1], row)}>
|
|
4722
|
+
{row.kind === 'date' ? (
|
|
4723
|
+
// 날짜 구분선 — 일어난 사실이 아니라 흐름의 경계라 시스템 안내 알약이 아니다.
|
|
4724
|
+
// 대화의 날짜는 `2026년 10월 6일 화요일` 로 쓴다 (§1-4 대화 예외)
|
|
4725
|
+
// 가로 구분선은 기본이 전체 폭이라 둘을 한 줄에 놓으면 앞의 것이 폭을 다 차지한다 —
|
|
4726
|
+
// 두 선이 남은 폭을 나눠 갖게 한다
|
|
4727
|
+
<div className="flex items-center gap-sd-12">
|
|
4728
|
+
<SDivider className="w-auto flex-1" />
|
|
4729
|
+
<span className="typo-body-sm-medium shrink-0 text-fg-tertiary">
|
|
4730
|
+
{formatChatDate(row.date)}
|
|
4731
|
+
</span>
|
|
4732
|
+
<SDivider className="w-auto flex-1" />
|
|
4733
|
+
</div>
|
|
4734
|
+
) : row.kind === 'system' ? (
|
|
4735
|
+
// 사람이 아니라 시스템이 알리는 사실 — 상담사 배정·종료 등 (§3-0 B)
|
|
4736
|
+
<SChatSystemMessage message={[row.text]} />
|
|
4737
|
+
) : (
|
|
4738
|
+
<SChatMessage
|
|
4739
|
+
direction={row.items[0].direction}
|
|
4740
|
+
sender={row.items[0].direction === 'incoming' ? consult.customer : undefined}
|
|
4741
|
+
time={row.items[0].time}
|
|
4742
|
+
// 읽음 표시는 보낸 메시지에만 붙는다
|
|
4743
|
+
readStatus={
|
|
4744
|
+
row.items[0].direction === 'outgoing'
|
|
4745
|
+
? row.items.every(m => m.read)
|
|
4746
|
+
? 'read'
|
|
4747
|
+
: 'unread'
|
|
4748
|
+
: undefined
|
|
4749
|
+
}
|
|
4750
|
+
variant={row.items[0].variant}
|
|
4751
|
+
messages={row.items.filter(m => m.text).map(m => m.text)}
|
|
4752
|
+
// 파일은 말풍선이 아니라 attachments 로 — messages 로 넘기면 칸이 두 겹이 된다
|
|
4753
|
+
attachments={row.items
|
|
4754
|
+
.filter(m => m.file)
|
|
4755
|
+
.map(m => (
|
|
4756
|
+
<SChatFile
|
|
4757
|
+
key={m.id}
|
|
4758
|
+
name={m.file!.name}
|
|
4759
|
+
size={m.file!.size}
|
|
4760
|
+
onFileClick={() => undefined}
|
|
4761
|
+
/>
|
|
4762
|
+
))}
|
|
4763
|
+
/>
|
|
4764
|
+
)}
|
|
4765
|
+
</div>
|
|
4766
|
+
))}
|
|
4767
|
+
</div>
|
|
4768
|
+
</SScrollArea>
|
|
4769
|
+
|
|
4770
|
+
{/* 입력 영역 — 구분선으로 대화와 나눈다. 좌우는 대화 영역과 같은 24 로 메시지와 끝을 맞추고,
|
|
4771
|
+
위아래는 구분선으로 떨어진 작은 영역이라 16 이다. 보내기 전 첨부는 입력창 위에 SChatAttachedFile 로 쌓는다 */}
|
|
4772
|
+
<SDivider />
|
|
4773
|
+
<div className="flex flex-col gap-sd-8 py-sd-16">
|
|
4774
|
+
{attached.length > 0 && (
|
|
4775
|
+
<div className="flex flex-row flex-wrap gap-sd-8 px-sd-24">
|
|
4776
|
+
{attached.map(name => (
|
|
4777
|
+
<SChatAttachedFile
|
|
4778
|
+
key={name}
|
|
4779
|
+
name={name}
|
|
4780
|
+
onRemove={() => setAttached(current => current.filter(n => n !== name))}
|
|
4781
|
+
/>
|
|
4782
|
+
))}
|
|
4783
|
+
</div>
|
|
4784
|
+
)}
|
|
4785
|
+
<SChatInput
|
|
4786
|
+
value={draft}
|
|
4787
|
+
disabled={closed}
|
|
4788
|
+
onValueChange={onDraftChange}
|
|
4789
|
+
onSend={send}
|
|
4790
|
+
onAttach={() => setAttached(current => [...current, `첨부_${current.length + 1}.png`])}
|
|
4791
|
+
// 상담 종료는 전송과 층이 다른 동작 — 입력창 아래 보조 액션 슬롯이다
|
|
4792
|
+
footerAction={!closed && <STextLink label="상담 종료" onClick={confirmClose} />}
|
|
4793
|
+
/>
|
|
4794
|
+
</div>
|
|
4795
|
+
</section>
|
|
4796
|
+
);
|
|
4797
|
+
}
|
|
4798
|
+
|
|
4799
|
+
/** 오른쪽 — 답변 템플릿. 같은 아코디언의 반복이라 목록이 자기 여백을 갖는다 (§2-2 상담 콘솔 예) */
|
|
4800
|
+
function TemplatePanel({ onPick }: { onPick: (body: string) => void }) {
|
|
4801
|
+
return (
|
|
4802
|
+
<section className={PANEL}>
|
|
4803
|
+
<div className={`${PANEL_HEADER} px-(--cmp-listItem-paddingX)`}>
|
|
4804
|
+
<span className="typo-heading-sm">답변 템플릿</span>
|
|
4805
|
+
<STooltip
|
|
4806
|
+
trigger="hover"
|
|
4807
|
+
icon="helpOutline"
|
|
4808
|
+
message={['누르면 입력창에 넣습니다. 보내기 전에 고칠 수 있습니다.']}
|
|
4809
|
+
/>
|
|
4810
|
+
</div>
|
|
4811
|
+
<SDivider />
|
|
4812
|
+
<SScrollArea axis="vertical" overlayScrollbar className="min-h-0 flex-1">
|
|
4813
|
+
{/* 분류(SExpansionItem) 안에 템플릿(SListItem)을 바로 넣는다 — 리스트는 루트에 하나 (§3-7-7) */}
|
|
4814
|
+
<SExpansionList className="pb-[var(--cmp-pageBody-padding-scrollEnd)]">
|
|
4815
|
+
{REPLY_TEMPLATES.map((group, i) => (
|
|
4816
|
+
<SExpansionItem key={group.id} title={group.name} defaultExpanded={i === 0}>
|
|
4817
|
+
{group.items.map(t => (
|
|
4818
|
+
<SListItem
|
|
4819
|
+
key={t.id}
|
|
4820
|
+
title={t.title}
|
|
4821
|
+
supportingText={t.body}
|
|
4822
|
+
supportingTextPosition="bottom"
|
|
4823
|
+
onClick={() => onPick(t.body)}
|
|
4824
|
+
/>
|
|
4825
|
+
))}
|
|
4826
|
+
</SExpansionItem>
|
|
4827
|
+
))}
|
|
4828
|
+
</SExpansionList>
|
|
4829
|
+
</SScrollArea>
|
|
4830
|
+
</section>
|
|
4831
|
+
);
|
|
4832
|
+
}
|
|
4833
|
+
```
|
|
4834
|
+
<!-- /recipe -->
|
|
4835
|
+
|
|
4836
|
+
### 4-13. 모달·드로어 — 목록에서 띄우는 작업
|
|
4837
|
+
|
|
4838
|
+
목록을 떠나지 않고 한 건을 다루는 흐름이다. **무엇을 하느냐로 그릇을 고른다.**
|
|
4839
|
+
|
|
4840
|
+
| 작업 | 그릇 | 여는 법 |
|
|
4841
|
+
| --- | --- | --- |
|
|
4842
|
+
| 목록을 보면서 한 건을 보고 고친다 | `SDrawer` — 옆에서 밀려 나온다 | 선언형 — 열린 행을 페이지가 든다 |
|
|
4843
|
+
| 짧은 입력으로 새로 만든다 (등록) | `SActionModal` | 명령형 — `SModal.create` (§3-3-4) |
|
|
4844
|
+
| 순서대로 거치는 짧은 작업 (엑셀 일괄 등록) | `SActionModal` + 가로 `SStepper` | 명령형 — `SModal.create` |
|
|
4845
|
+
| 입력이 길어 여러 섹션이 필요하다 | 모달이 아니라 페이지 (§4-3) | 라우트 |
|
|
4846
|
+
|
|
4847
|
+
- **모달·드로어의 푸터 버튼은 `SForm` 밖에 그려진다.** 본문의 `SForm` 은 푸터의 `type="submit"` 을 받지 못하므로, 저장 버튼에서 **`SForm` 의 ref `validate()`** 로 검증하고 실패하면 `focusFirstInvalid()` 로 첫 필드에 보낸다. 페이지의 카드 푸터(§4-11)는 `SForm` 이 카드를 감싸므로 submit 이 닿는다 — 이 차이를 섞지 않는다.
|
|
4848
|
+
- **`button` 은 눌러도 닫지 않는다.** 저장이 끝난 뒤에 모달은 `modalRef.ok()`, 드로어는 닫기 상태로 닫는다. 목록 갱신은 `SModal.create(...).onOk()` 에서 한다.
|
|
4849
|
+
- **푸터는 `md` 고정**이고 `footerLeft` 슬롯 버튼에도 `size="md"` 를 명시한다 (§3-5-2). 등록 모달의 취소는 `footerLeft`, 드로어의 삭제(한 건의 파괴 액션)는 `footerLeft` 에 `danger outline` 이다 (§3-5-3).
|
|
4850
|
+
- **닫기 경로는 기본값 그대로다.** `SActionModal`·`SDrawer` 는 입력을 담으므로 바깥 클릭·ESC 로 닫히지 않는 것이 기본이다 (§3-3-5) — `persistent` 를 주지 않는다.
|
|
4851
|
+
- **드로어는 본문 여백을 넣지 않는다** — 모달은 여백 토큰을 스스로 넣지만, 드로어는 회색 바탕(토큰)만 깔고 여백은 담는 쪽이 준다. 직접 만든 영역 규칙(§2-2)으로 준다. 흰 표·카드가 회색 바탕 위에 놓여 경계가 읽힌다.
|
|
4852
|
+
- **모달 높이는 주지 않는다** (§3-3-1) — 내용이 정하고 85% 에서 멈춘다. **그때 스크롤되는 것은 본문이 아니라 표다** — 본문 바로 아래 `flex h-full min-h-0 flex-col` 상자를 두고 표에 `min-h-0 flex-1` 을 준다. 그러면 단계형 모달의 2단계처럼 표가 길어져도 스텝퍼·안내·푸터는 제자리에 있고 표가 남은 높이를 채운다. 건너뛸 행 수처럼 표에 대한 한 줄 상태는 표 바로 위 `SContextAlert` 다 (§3-2-1).
|
|
4853
|
+
- **모달 안 단계형은 페이지 단계형(§4-11 가로형)과 같은 구성이다** — 본문 맨 위 가운데 스텝퍼, 이전은 `footerLeft`, 다음·실행은 `button`, 그만두기는 닫기(X). 단계마다 바뀌는 `button` 은 `SActionModalProps['button']` 으로 타입을 붙인다.
|
|
4854
|
+
- **드로어는 행 클릭(`onRowClick`)이 아니라 이름 셀의 링크로 연다** — 상품명·주문번호처럼 그 행을 가리키는 셀을 `STextLink` 로 그린다. 행 전체 클릭은 누를 수 있다는 신호가 없고 키보드로 닿지 않는다. 별도 "상세" 버튼 열은 폭만 먹으므로 두지 않는다. 드로어는 화면을 떠나지 않고 그 위에서 고치는 자리라 그 열의 표식은 `navigable` 이 아니라 **`editable`** 이다 (§3-4).
|
|
4855
|
+
- 링크가 여는 함수를 받아야 하므로 컬럼은 컴포넌트 안에서 `useMemo` 로 한 번만 만든다 — 렌더마다 새 배열을 넘기면 표가 열 상태를 다시 잡는다.
|
|
4856
|
+
- 행마다 드로어 폼을 `key` 로 새로 세운다 — 편집 값이 다른 행으로 새지 않는다.
|
|
4857
|
+
|
|
4858
|
+
<!-- recipe:src/recipes/ProductOverlayPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
|
|
4859
|
+
```tsx
|
|
4860
|
+
import { useMemo, useRef, useState } from 'react';
|
|
4861
|
+
import {
|
|
4862
|
+
SActionModal,
|
|
4863
|
+
SButton,
|
|
4864
|
+
SContextAlert,
|
|
4865
|
+
SDrawer,
|
|
4866
|
+
SForm,
|
|
4867
|
+
SKeyValueTable,
|
|
4868
|
+
SModal,
|
|
4869
|
+
SRadioCardGroup,
|
|
4870
|
+
SStepper,
|
|
4871
|
+
STable,
|
|
4872
|
+
STableBar,
|
|
4873
|
+
STag,
|
|
4874
|
+
STextLink,
|
|
4875
|
+
type SActionModalProps,
|
|
4876
|
+
type SFormHandle,
|
|
4877
|
+
type SKeyValueField,
|
|
4878
|
+
type SModalCreateComponentProps,
|
|
4879
|
+
type STableColumn,
|
|
4880
|
+
} from 'sellmate-design-system-react';
|
|
4881
|
+
import AppShell from './AppShell';
|
|
4882
|
+
import {
|
|
4883
|
+
CATEGORY_OPTIONS,
|
|
4884
|
+
STATUS_OPTIONS,
|
|
4885
|
+
commitUpload,
|
|
4886
|
+
removeProducts,
|
|
4887
|
+
saveProduct,
|
|
4888
|
+
useProductList,
|
|
4889
|
+
validateUpload,
|
|
4890
|
+
type Product,
|
|
4891
|
+
type UploadRow,
|
|
4892
|
+
} from './api';
|
|
4893
|
+
|
|
4894
|
+
/** 상품명 셀이 드로어를 여는 트리거라 여는 함수를 받아 컬럼을 만든다 */
|
|
4895
|
+
const makeColumns = (open: (row: Product) => void): STableColumn<Product>[] => [
|
|
4896
|
+
{ name: 'id', label: 'ID', field: 'id', width: 80, align: 'center' },
|
|
4897
|
+
// 무엇을 여는지 이름이 말하는 링크로 연다 — 행 전체 클릭은 누를 수 있다는 신호가 없고 키보드로 닿지 않는다.
|
|
4898
|
+
// 드로어는 화면을 떠나지 않고 그 위에서 고치는 자리라 navigable 이 아니라 editable 표식이다 (§3-4)
|
|
4899
|
+
{
|
|
4900
|
+
name: 'name',
|
|
4901
|
+
label: '상품명',
|
|
4902
|
+
field: 'name',
|
|
4903
|
+
width: 240,
|
|
4904
|
+
editable: true,
|
|
4905
|
+
render: row => (
|
|
4906
|
+
<STextLink
|
|
4907
|
+
label={row.name}
|
|
4908
|
+
labelClassName="text-oceanblue-75"
|
|
4909
|
+
underline
|
|
4910
|
+
onClick={() => open(row)}
|
|
4911
|
+
/>
|
|
4912
|
+
),
|
|
4913
|
+
},
|
|
4914
|
+
{
|
|
4915
|
+
name: 'price',
|
|
4916
|
+
label: '판매가',
|
|
4917
|
+
field: 'price',
|
|
4918
|
+
width: 120,
|
|
4919
|
+
align: 'right',
|
|
4920
|
+
format: v => `${Number(v).toLocaleString()}원`,
|
|
4921
|
+
},
|
|
4922
|
+
{
|
|
4923
|
+
name: 'status',
|
|
4924
|
+
label: '상태',
|
|
4925
|
+
field: 'status',
|
|
4926
|
+
width: 100,
|
|
4927
|
+
align: 'center',
|
|
4928
|
+
contentType: 'control',
|
|
4929
|
+
render: row =>
|
|
4930
|
+
row.status === 'selling' ? (
|
|
4931
|
+
<STag size="sm" color="green" label="판매중" />
|
|
4932
|
+
) : (
|
|
4933
|
+
<STag size="sm" color="grey" label="판매중지" />
|
|
4934
|
+
),
|
|
4935
|
+
},
|
|
4936
|
+
];
|
|
4937
|
+
|
|
4938
|
+
/** 등록 모달·상세 드로어가 같이 쓰는 입력 표 */
|
|
4939
|
+
const productFields: SKeyValueField[][] = [
|
|
4940
|
+
[
|
|
4941
|
+
{
|
|
4942
|
+
name: 'name',
|
|
4943
|
+
label: '상품명',
|
|
4944
|
+
type: 'input',
|
|
4945
|
+
required: true,
|
|
4946
|
+
options: { width: '100%', rules: [(v: unknown) => !!v || '상품명을 입력해 주세요'] },
|
|
4947
|
+
},
|
|
4948
|
+
],
|
|
4949
|
+
[
|
|
4950
|
+
{
|
|
4951
|
+
name: 'category',
|
|
4952
|
+
label: '카테고리',
|
|
4953
|
+
type: 'select',
|
|
4954
|
+
required: true,
|
|
4955
|
+
options: {
|
|
4956
|
+
options: CATEGORY_OPTIONS,
|
|
4957
|
+
valueAsPrimitive: true,
|
|
4958
|
+
rules: [(v: unknown) => !!v || '카테고리를 골라 주세요'],
|
|
4959
|
+
},
|
|
4960
|
+
},
|
|
4961
|
+
],
|
|
4962
|
+
[
|
|
4963
|
+
{
|
|
4964
|
+
name: 'price',
|
|
4965
|
+
label: '판매가',
|
|
4966
|
+
type: 'number-input',
|
|
4967
|
+
options: { min: 0, inputSuffix: '원' },
|
|
4968
|
+
},
|
|
4969
|
+
],
|
|
4970
|
+
[{ name: 'status', label: '상태', type: 'radio', options: { options: STATUS_OPTIONS } }],
|
|
4971
|
+
];
|
|
4972
|
+
|
|
4973
|
+
type Values = Record<string, unknown>;
|
|
4974
|
+
|
|
4975
|
+
export default function ProductOverlayPage() {
|
|
4976
|
+
const [page, setPage] = useState(1);
|
|
4977
|
+
const { rows, total, lastPage } = useProductList({ page, rowsPerPage: 20 });
|
|
4978
|
+
// 드로어는 선언형으로 연다 — 열린 행 하나를 페이지가 든다
|
|
4979
|
+
const [opened, setOpened] = useState<Product | null>(null);
|
|
4980
|
+
// 컬럼은 한 번만 만든다 — 렌더마다 새 배열을 넘기면 표가 열 상태를 다시 잡는다 (setOpened 는 바뀌지 않는다)
|
|
4981
|
+
const columns = useMemo(() => makeColumns(setOpened), []);
|
|
4982
|
+
|
|
4983
|
+
// 모달은 명령형으로 띄운다 — SActionModal 을 루트로 하는 컴포넌트를 SModal.create 에 넘긴다 (§3-3-4)
|
|
4984
|
+
const openCreate = () => SModal.create({ component: ProductCreateModal }).onOk(() => setPage(1));
|
|
4985
|
+
const openUpload = () => SModal.create({ component: BulkUploadModal }).onOk(() => setPage(1));
|
|
4986
|
+
|
|
4987
|
+
return (
|
|
4988
|
+
<AppShell header={{ variant: 'bar', title: '상품 관리' }} contentHeight="fill">
|
|
4989
|
+
<div className="flex h-full min-h-0 flex-col gap-sd-12">
|
|
4990
|
+
{/* 툴바+표 = 한 블록 (§2-0) */}
|
|
4991
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
4992
|
+
<STableBar
|
|
4993
|
+
className="border-b-0"
|
|
4994
|
+
total={total}
|
|
4995
|
+
rightActions={
|
|
4996
|
+
<div className="flex items-center gap-sd-8">
|
|
4997
|
+
<SButton
|
|
4998
|
+
size="sm"
|
|
4999
|
+
color="neutral"
|
|
5000
|
+
outline
|
|
5001
|
+
|
|
5002
|
+
label="엑셀 일괄 등록"
|
|
5003
|
+
onClick={openUpload}
|
|
5004
|
+
/>
|
|
5005
|
+
{/* 이 페이지의 유일한 primary 채움 (§3-5-1) */}
|
|
5006
|
+
<SButton size="sm" label="상품 등록" onClick={openCreate} />
|
|
5007
|
+
</div>
|
|
5008
|
+
}
|
|
5009
|
+
/>
|
|
5010
|
+
{/* 상품명을 누르면 목록을 떠나지 않고 옆에서 상세가 밀려 나온다 */}
|
|
5011
|
+
<STable<Product>
|
|
5012
|
+
className="min-h-0 flex-1"
|
|
5013
|
+
columns={columns}
|
|
5014
|
+
rows={rows}
|
|
5015
|
+
rowKey="id"
|
|
5016
|
+
pagination={{ page, rowsPerPage: 20, lastPage }}
|
|
5017
|
+
onPageChange={setPage}
|
|
5018
|
+
/>
|
|
5019
|
+
</div>
|
|
5020
|
+
</div>
|
|
5021
|
+
|
|
5022
|
+
{/* key 로 행마다 폼을 새로 세운다 — 편집 값이 다른 행으로 새지 않는다 */}
|
|
5023
|
+
{opened && <ProductDrawer key={opened.id} product={opened} onClose={() => setOpened(null)} />}
|
|
5024
|
+
</AppShell>
|
|
5025
|
+
);
|
|
5026
|
+
}
|
|
5027
|
+
|
|
5028
|
+
/**
|
|
5029
|
+
* 상세 드로어 — 목록을 보면서 한 건을 고친다.
|
|
5030
|
+
* 레이어라 어디서 띄워도 레이아웃에 영향이 없다 (§2-0). 닫기 경로는 X 와 푸터뿐이다 — 입력을 담으므로
|
|
5031
|
+
* 바깥 클릭·ESC 로 닫히지 않는 것이 기본이고 그대로 둔다 (§3-3-5)
|
|
5032
|
+
*/
|
|
5033
|
+
function ProductDrawer({ product, onClose }: { product: Product; onClose: () => void }) {
|
|
5034
|
+
const [values, setValues] = useState<Values>(product);
|
|
5035
|
+
// 푸터 버튼은 SForm 밖에서 그려진다 — submit 이 폼에 닿지 않으므로 ref 로 검증한다
|
|
5036
|
+
const formRef = useRef<SFormHandle>(null);
|
|
5037
|
+
|
|
5038
|
+
const save = async () => {
|
|
5039
|
+
if (!formRef.current?.validate()) return formRef.current?.focusFirstInvalid();
|
|
5040
|
+
await saveProduct(values);
|
|
5041
|
+
onClose();
|
|
5042
|
+
};
|
|
5043
|
+
const confirmRemove = () =>
|
|
5044
|
+
SModal.confirm({
|
|
5045
|
+
type: 'negative',
|
|
5046
|
+
modalTitle: '상품을 삭제할까요?',
|
|
5047
|
+
topMessage: ['삭제된 상품은 복구할 수 없습니다.'],
|
|
5048
|
+
mainButtonLabel: '삭제',
|
|
5049
|
+
}).onOk(async () => {
|
|
5050
|
+
await removeProducts([product.id]);
|
|
5051
|
+
onClose();
|
|
5052
|
+
});
|
|
5053
|
+
|
|
5054
|
+
return (
|
|
5055
|
+
<SDrawer
|
|
5056
|
+
open
|
|
5057
|
+
onOpenChange={next => !next && onClose()}
|
|
5058
|
+
onClose={onClose}
|
|
5059
|
+
title={product.name}
|
|
5060
|
+
// 푸터는 md 고정 — footerLeft 슬롯 버튼에도 md 를 명시한다 (§3-5-2).
|
|
5061
|
+
// 항목 하나의 삭제라 danger outline, 왼쪽 끝 (§3-5-3·§3-5-6)
|
|
5062
|
+
footerLeft={<SButton color="danger" outline size="md" label="삭제" onClick={confirmRemove} />}
|
|
5063
|
+
button={{ label: '저장', onClick: save }}
|
|
5064
|
+
>
|
|
5065
|
+
{/* 드로어는 모달과 달리 본문 여백을 넣지 않는다 — 회색 바탕(토큰)만 깔고 여백은 담는 쪽이 준다.
|
|
5066
|
+
표 하나뿐이라 직접 만든 영역의 여백은 16 이다 (§2-2). 흰 표가 회색 바탕 위에 놓여 경계가 읽힌다 */}
|
|
5067
|
+
<SForm ref={formRef} formClassName="p-sd-16">
|
|
5068
|
+
<SKeyValueTable
|
|
5069
|
+
fields={productFields}
|
|
5070
|
+
values={values}
|
|
5071
|
+
onChange={({ values }) => setValues(values)}
|
|
5072
|
+
/>
|
|
5073
|
+
</SForm>
|
|
5074
|
+
</SDrawer>
|
|
5075
|
+
);
|
|
5076
|
+
}
|
|
5077
|
+
|
|
5078
|
+
/** 등록 모달 — 짧은 폼. 높이는 내용이 정한다 (§3-3-1) */
|
|
5079
|
+
function ProductCreateModal({ open, onOpenChange, onClose, modalRef }: SModalCreateComponentProps) {
|
|
5080
|
+
const [values, setValues] = useState<Values>({ status: 'selling' });
|
|
5081
|
+
const formRef = useRef<SFormHandle>(null);
|
|
5082
|
+
|
|
5083
|
+
const save = async () => {
|
|
5084
|
+
if (!formRef.current?.validate()) return formRef.current?.focusFirstInvalid();
|
|
5085
|
+
await saveProduct(values);
|
|
5086
|
+
modalRef.ok(); // 저장이 끝난 뒤에 닫는다 — button 은 눌러도 모달을 닫지 않는다
|
|
5087
|
+
};
|
|
5088
|
+
|
|
5089
|
+
return (
|
|
5090
|
+
<SActionModal
|
|
5091
|
+
open={open}
|
|
5092
|
+
onOpenChange={onOpenChange}
|
|
5093
|
+
onClose={onClose}
|
|
5094
|
+
modalTitle="상품 등록"
|
|
5095
|
+
width={640}
|
|
5096
|
+
height={400}
|
|
5097
|
+
footerLeft={
|
|
5098
|
+
<SButton color="neutral" outline size="md" label="취소" onClick={() => modalRef.cancel()} />
|
|
5099
|
+
}
|
|
5100
|
+
button={{ label: '등록', onClick: save }}
|
|
5101
|
+
>
|
|
5102
|
+
<SForm ref={formRef}>
|
|
5103
|
+
<SKeyValueTable
|
|
5104
|
+
fields={productFields}
|
|
5105
|
+
values={values}
|
|
5106
|
+
onChange={({ values }) => setValues(values)}
|
|
5107
|
+
/>
|
|
5108
|
+
</SForm>
|
|
5109
|
+
</SActionModal>
|
|
5110
|
+
);
|
|
5111
|
+
}
|
|
5112
|
+
|
|
5113
|
+
const UPLOAD_STEPS = [
|
|
5114
|
+
{ label: '파일 선택', value: 'file' },
|
|
5115
|
+
{ label: '검증 결과 확인', value: 'check' },
|
|
5116
|
+
];
|
|
5117
|
+
|
|
5118
|
+
const MODE_OPTIONS = [
|
|
5119
|
+
{ value: 'create', label: '신규 등록', subtitle: '파일의 모든 행을 새 상품으로 등록합니다.' },
|
|
5120
|
+
{ value: 'update', label: '기존 상품 수정', subtitle: '상품코드가 같은 상품의 값을 덮어씁니다.' },
|
|
5121
|
+
];
|
|
5122
|
+
|
|
5123
|
+
const fileFields: SKeyValueField[][] = [
|
|
5124
|
+
[
|
|
5125
|
+
{
|
|
5126
|
+
name: 'file',
|
|
5127
|
+
label: '엑셀 파일',
|
|
5128
|
+
type: 'file-picker',
|
|
5129
|
+
required: true,
|
|
5130
|
+
options: {
|
|
5131
|
+
accept: '.xlsx',
|
|
5132
|
+
placeholder: '파일을 업로드 해주세요.',
|
|
5133
|
+
rules: [(v: unknown) => !!v || '파일을 선택해 주세요'],
|
|
5134
|
+
},
|
|
5135
|
+
},
|
|
5136
|
+
],
|
|
5137
|
+
];
|
|
5138
|
+
|
|
5139
|
+
const resultColumns: STableColumn<UploadRow>[] = [
|
|
5140
|
+
{ name: 'row', label: '행', field: 'row', width: 64, align: 'center' },
|
|
5141
|
+
{ name: 'name', label: '상품명', field: 'name', width: 200 },
|
|
5142
|
+
{
|
|
5143
|
+
name: 'result',
|
|
5144
|
+
label: '결과',
|
|
5145
|
+
field: 'error',
|
|
5146
|
+
width: 200,
|
|
5147
|
+
contentType: 'control',
|
|
5148
|
+
render: row =>
|
|
5149
|
+
row.error ? (
|
|
5150
|
+
<STag size="sm" color="red" label={row.error} />
|
|
5151
|
+
) : (
|
|
5152
|
+
<STag size="sm" color="green" label="정상" />
|
|
5153
|
+
),
|
|
5154
|
+
},
|
|
5155
|
+
];
|
|
5156
|
+
|
|
5157
|
+
/**
|
|
5158
|
+
* 모달 안 단계형 — 페이지 단계형(§4-11 가로형)과 같은 구성이다.
|
|
5159
|
+
* 스텝퍼는 본문 맨 위 가운데, 이전은 footerLeft, 다음·등록은 button. 그만두기는 모달의 닫기(X)
|
|
5160
|
+
*/
|
|
5161
|
+
function BulkUploadModal({ open, onOpenChange, onClose, modalRef }: SModalCreateComponentProps) {
|
|
5162
|
+
const [step, setStep] = useState(0);
|
|
5163
|
+
const [values, setValues] = useState<Values>({ mode: 'create' });
|
|
5164
|
+
const [rows, setRows] = useState<UploadRow[]>([]);
|
|
5165
|
+
const formRef = useRef<SFormHandle>(null);
|
|
5166
|
+
const valid = rows.filter(r => !r.error).length;
|
|
5167
|
+
|
|
5168
|
+
const next = async () => {
|
|
5169
|
+
if (!formRef.current?.validate()) return formRef.current?.focusFirstInvalid();
|
|
5170
|
+
setRows(await validateUpload(values));
|
|
5171
|
+
setStep(1);
|
|
5172
|
+
};
|
|
5173
|
+
const commit = async () => {
|
|
5174
|
+
await commitUpload(rows);
|
|
5175
|
+
modalRef.ok();
|
|
5176
|
+
};
|
|
5177
|
+
|
|
5178
|
+
// 단계마다 바뀌는 버튼은 타입을 붙여 둔다 — 아이콘 이름이 string 으로 넓어지지 않게 (§4-11)
|
|
5179
|
+
const button: SActionModalProps['button'] =
|
|
5180
|
+
step === 0
|
|
5181
|
+
? { label: '다음', rightIcon: 'arrowRight', onClick: next }
|
|
5182
|
+
: { label: `${valid.toLocaleString()}개 등록`, disabled: valid === 0, onClick: commit };
|
|
5183
|
+
|
|
5184
|
+
return (
|
|
5185
|
+
<SActionModal
|
|
5186
|
+
open={open}
|
|
5187
|
+
onOpenChange={onOpenChange}
|
|
5188
|
+
onClose={onClose}
|
|
5189
|
+
modalTitle="엑셀 일괄 등록"
|
|
5190
|
+
width={720}
|
|
5191
|
+
height={600}
|
|
5192
|
+
footerLeft={
|
|
5193
|
+
<SButton
|
|
5194
|
+
color="neutral"
|
|
5195
|
+
outline
|
|
5196
|
+
size="md"
|
|
5197
|
+
label="이전"
|
|
5198
|
+
icon="arrowLeft"
|
|
5199
|
+
disabled={step === 0}
|
|
5200
|
+
onClick={() => setStep(0)}
|
|
5201
|
+
/>
|
|
5202
|
+
}
|
|
5203
|
+
button={button}
|
|
5204
|
+
>
|
|
5205
|
+
<div className="flex h-full min-h-0 flex-col gap-sd-16">
|
|
5206
|
+
<div className="flex justify-center">
|
|
5207
|
+
<SStepper
|
|
5208
|
+
items={UPLOAD_STEPS.map((s, i) => ({ ...s, completed: i < step }))}
|
|
5209
|
+
value={UPLOAD_STEPS[step].value}
|
|
5210
|
+
/>
|
|
5211
|
+
</div>
|
|
5212
|
+
{step === 0 ? (
|
|
5213
|
+
<SForm ref={formRef} formClassName="flex flex-col gap-sd-12">
|
|
5214
|
+
<SRadioCardGroup
|
|
5215
|
+
options={MODE_OPTIONS}
|
|
5216
|
+
value={values.mode as string}
|
|
5217
|
+
onValueChange={v => setValues({ ...values, mode: v })}
|
|
5218
|
+
/>
|
|
5219
|
+
<SKeyValueTable
|
|
5220
|
+
fields={fileFields}
|
|
5221
|
+
values={values}
|
|
5222
|
+
onChange={({ values }) => setValues(values)}
|
|
5223
|
+
/>
|
|
5224
|
+
</SForm>
|
|
5225
|
+
) : (
|
|
5226
|
+
<div className="flex min-h-0 flex-1 flex-col gap-sd-12">
|
|
5227
|
+
{rows.length > valid && (
|
|
5228
|
+
<SContextAlert
|
|
5229
|
+
icon="notificationOutline"
|
|
5230
|
+
iconColor="orange_65"
|
|
5231
|
+
intent="warning"
|
|
5232
|
+
messages={[
|
|
5233
|
+
`${(rows.length - valid).toLocaleString()}개의 행에 오류가 있어 건너뜁니다.`,
|
|
5234
|
+
]}
|
|
5235
|
+
/>
|
|
5236
|
+
)}
|
|
5237
|
+
<STable<UploadRow>
|
|
5238
|
+
className="min-h-0 flex-1"
|
|
5239
|
+
columns={resultColumns}
|
|
5240
|
+
rows={rows}
|
|
5241
|
+
rowKey="id"
|
|
5242
|
+
/>
|
|
5243
|
+
</div>
|
|
5244
|
+
)}
|
|
5245
|
+
</div>
|
|
5246
|
+
</SActionModal>
|
|
5247
|
+
);
|
|
5248
|
+
}
|
|
5249
|
+
```
|
|
5250
|
+
<!-- /recipe -->
|
|
5251
|
+
|
|
3235
5252
|
---
|
|
3236
5253
|
|
|
3237
5254
|
## 5. 자가 점검 체크리스트
|
|
@@ -3276,7 +5293,7 @@ export default function ExcelFilePopupPage() {
|
|
|
3276
5293
|
- [ ] 스크롤 위치를 저장했다 되돌린다면 **행 번호**(`getFirstVisibleRow` → `scrollToRow`)로 했는가, `scrollTop` 픽셀을 저장하지는 않았는가 (§3-4 — 가상 스크롤에서는 좌표계가 실측으로 움직여 다른 행이 나온다)
|
|
3277
5294
|
- [ ] `editable` · `navigable` 표식을 켠 열이 **셀에서도 실제로 그렇게 동작하는가** (입력 컨트롤 · 링크가 있는가), **모달로 처리하는 열에 `navigable` 을 켜지는 않았는가** (화면을 떠나지 않으므로 `editable` 이다), 표식을 붙인 열의 폭을 함께 넓혔는가 (§3-4)
|
|
3278
5295
|
- [ ] 금액·수량 등 양을 나타내는 숫자에 빠짐없이 `toLocaleString()` 을 썼는가 (번호·코드는 제외)
|
|
3279
|
-
- [ ] 날짜를 `YYYY-MM-DD` 로 표기했는가 (`toLocaleDateString()` ❌, §1-4)
|
|
5296
|
+
- [ ] 날짜를 `YYYY-MM-DD` 로 표기했는가 (`toLocaleDateString()` ❌, §1-4) — 대화 말풍선 시각(`오후 5:52`)과 대화의 날짜 구분선(`2026년 10월 6일 화요일`)만 예외
|
|
3280
5297
|
- [ ] 필드 폭을 등급(`'xs' | 'sm' | 'md' | 'lg' | 'xl'`)으로 줬는가 — px 직접 지정 ❌ (§4-3)
|
|
3281
5298
|
- [ ] 조회 조건으로 쓰는 셀렉트·날짜·시간 피커에 `clearable` 을 줬는가, 그 상태가 `null` 을 담을 수 있는가 (§3-7-4 — 필수 입력 필드에는 켜지 않는다)
|
|
3282
5299
|
- [ ] 서버에서 페이지 단위로 받는 `SSelect` 에 `onReachEnd` 와 `hasMore`·`loading`·`serverSearch` 를 함께 줬는가, 늦게 온 응답을 버리는 cleanup 이 있는가 (§3-7-2 — 렌더 최적화는 DS 가 알아서 한다)
|