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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/llms.txt CHANGED
@@ -317,6 +317,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
317
317
  | 상황 | 값 |
318
318
  | --- | --- |
319
319
  | **페이지 콘텐츠 패딩** | **`SPage` 가 `--cmp-pageBody-padding-default` 로 이미 넣는다.** 직접 주지 않는다 (덮어쓰면 토큰이 바뀌어도 안 따라간다). `SPage` 밖에서 같은 패딩이 필요하면 `p-sd-16` |
320
+ | **가장자리까지 차는 블록이 맨 위에 서는 페이지** (`SChipFilter` 바 등) | `SPage` 에 `padding="none"` 으로 끄고, **그 아래 블록이 `p-[var(--cmp-pageBody-padding-default)]` 로 같은 토큰의 여백을 잡는다.** `className="p-0"` 이나 `p-sd-16` 으로 적지 않는다 (§4-2 칩 필터) |
320
321
  | **섹션 ↔ 섹션**, **블록 ↔ 블록** (헤더·필터·본문 사이) | **`gap-sd-12`** |
321
322
  | **툴바 ↔ 그 표** (`STableBar` ↔ `STable`) | **간격 없음 — 붙인다.** 둘은 한 블록이다 (§2-0) |
322
323
  | 요소 ↔ 요소 | **기본 `gap-sd-8`** (`--sys-space-stack-gap-element-normal`) · 타이트 `gap-sd-4`(`-tight`) · 여유 `gap-sd-12`(`-relaxed`) / `gap-sd-16`(`-wide`) |
@@ -416,7 +417,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
416
417
  ```
417
418
 
418
419
  - **`bordered={false}`** 로 표의 테두리를 끈다. 카드가 이미 그린다.
419
- - **`radius="useTop"`** 으로 위쪽 모서리를 죽인다. 아래쪽 라운드는 카드가 처리한다.
420
+ - **`radius="useTop"`** 으로 위쪽 모서리를 죽인다. 아래쪽 라운드는 카드가 처리한다. **표 아래에 카드 푸터(`button`·`footerLeft`)가 오면 `radius="full"`** — 아래 모서리도 카드가 아니라 푸터의 상단 구분선이 이어받으므로 네 모서리를 모두 죽인다.
420
421
  - `STable` 은 **페이지네이션 바의 테두리와 `-mt-px` 겹침도 함께 꺼진다** — 본문 테두리가 없으면 겹칠 대상이 없어, 그대로 두면 페이지네이션만 테두리를 갖고 1px 어긋난다.
421
422
 
422
423
  #### 본문 바탕 눌러앉히기 (선택)
@@ -1361,6 +1362,7 @@ tableRef.current.scrollToRow(row); // 복원
1361
1362
  - **왼쪽 끝**: 취소·닫기·목록 등 되돌리는 액션
1362
1363
  - **오른쪽 끝**: 저장·등록·수정·삭제 등 실행 액션
1363
1364
  - 체크박스·안내 문구 등 **부가 요소는 오른쪽 그룹 안, 실행 버튼 바로 왼쪽**에 둔다
1365
+ - **되돌리는 액션이 없어도 양끝 분리는 그대로다** — 오른쪽에 모으지 않는다. 그때는 **위계가 낮은 실행 액션이 왼쪽 끝, 가장 강한 실행 액션이 오른쪽 끝**이다. 뒤로가기를 헤더의 `onBack` 이 맡은 상세 페이지(§4-4)의 `삭제`(`danger outline`) · `수정`(`primary` 채움)이 이 경우다.
1364
1366
 
1365
1367
  ```tsx
1366
1368
  <div className="flex items-center justify-between">
@@ -1961,6 +1963,8 @@ const [selectedId, setSelectedId] = useState<string>();
1961
1963
  >
1962
1964
  > **아래 레시피는 §2-0 조합 문법으로 유도된 결과다.** 여기 없는 화면(대시보드·설정·마법사 등)을 만들 때는 임의로 짜지 말고 §2-0 의 층 구조·포함 규칙·블록 순서로 직접 유도한다.
1963
1965
  >
1966
+ > **레시피 코드의 `./AppShell` 은 §4-1 의 셸, `./api`·`./router` 는 앱의 데이터 계층·라우터 자리다.** 각 레시피는 Storybook `Recipes/…` 에서 실제로 띄워 볼 수 있고, 아래에 적힌 분기(칩 필터·섹션 나눔 등)의 변형 코드도 거기 있다.
1967
+ >
1964
1968
  > **핵심 원칙 — 표 형태의 정보는 `SKeyValueTable` 로 만든다.** 필터·등록/수정 폼·상세 정보가 모두 여기 해당한다.
1965
1969
  > `SField` 컨트롤을 `div` 로 직접 나열해 폼을 만들지 않는다.
1966
1970
  > (필터만 예외가 하나 있다 — 조건 후보가 많아 골라 붙이게 해야 하면 `SChipFilter` 다. §3-7-11)
@@ -1968,7 +1972,11 @@ const [selectedId, setSelectedId] = useState<string>();
1968
1972
  ### 4-1. 앱 셸 (모든 페이지 공통)
1969
1973
 
1970
1974
  ```tsx
1971
- import { SLayout, SGnb, SPage, SPageHeader, type SGnbMenuItem, type SPageHeaderProps } from 'sellmate-design-system-react';
1975
+ import type { ReactNode } from 'react';
1976
+ import {
1977
+ SLayout, SGnb, SPage, SPageHeader,
1978
+ type SGnbMenuItem, type SPageHeaderProps, type SPageContentHeight, type SPageContentWidth, type SPagePadding,
1979
+ } from 'sellmate-design-system-react';
1972
1980
 
1973
1981
  const MENU: SGnbMenuItem[] = [
1974
1982
  { label: '주문', value: 'orders', icon: 'bill' },
@@ -1980,12 +1988,15 @@ export default function AppShell({
1980
1988
  header,
1981
1989
  scrollEndSpacing,
1982
1990
  contentHeight,
1991
+ contentWidth,
1992
+ padding,
1983
1993
  }: {
1984
- children: React.ReactNode;
1994
+ children: ReactNode;
1985
1995
  header?: SPageHeaderProps;
1986
1996
  scrollEndSpacing?: boolean;
1987
1997
  contentHeight?: SPageContentHeight;
1988
1998
  contentWidth?: SPageContentWidth;
1999
+ padding?: SPagePadding;
1989
2000
  }) {
1990
2001
  return (
1991
2002
  <SLayout type="box" header="fix">
@@ -1994,7 +2005,8 @@ export default function AppShell({
1994
2005
  {/* 페이지 제목은 SPage 앞에 형제로 둔다 — SLayout 이 헤더부터 뒤를 한 열로 묶는다.
1995
2006
  header 는 페이지마다 달라 AppShell 이 그대로 받아 넘긴다 — 여기서 만들지 않는다 */}
1996
2007
  {header && <SPageHeader {...header} />}
1997
- {/* 콘텐츠 패딩은 SPage 가 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다 */}
2008
+ {/* 콘텐츠 패딩은 SPage 가 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다.
2009
+ 가장자리까지 차는 블록이 맨 위에 서는 페이지만 padding="none" 으로 끈다 (§4-2 칩 필터) */}
1998
2010
  {/* 높이 모드는 페이지가 정한다 — 대부분 contentHeight="fill" 이다 (§2-2) */}
1999
2011
  {/* 스크롤 끝 여백도 SPage 가 넣는다. 페이지가 실제로 스크롤되는 화면에서만 켠다 */}
2000
2012
  {/* 가로 폭 모드도 페이지가 정한다 — 넓은 창에서 본문을 늘릴지 최소 폭으로 세울지 */}
@@ -2003,6 +2015,7 @@ export default function AppShell({
2003
2015
  scrollEndSpacing={scrollEndSpacing}
2004
2016
  contentHeight={contentHeight}
2005
2017
  contentWidth={contentWidth}
2018
+ padding={padding}
2006
2019
  >
2007
2020
  {children}
2008
2021
  </SPage>
@@ -2208,7 +2221,7 @@ import { SModalOutlet } from 'sellmate-design-system-react';
2208
2221
 
2209
2222
  **뒤의 둘은 한 블록이라 붙여 쓴다** — 툴바는 자기 아래 표의 건수와 액션을 말하므로 사이를 벌리지 않는다. 묶는 방법과 맞닿는 변 처리는 §2-0 에 있다.
2210
2223
 
2211
- 필터 자리는 `SChipFilter` 로 바꿔 놓을 수 있다 — 조건 후보가 많아 쓸 것만 골라 붙이게 하는 화면이면 그쪽이다 (§3-7-11). 나머지 골격은 같다.
2224
+ 필터 자리는 `SChipFilter` 로 바꿔 놓을 수 있다 — 조건 후보가 많아 쓸 것만 골라 붙이게 하는 화면이면 그쪽이다 (§3-7-11). 나머지 골격은 같지만 여백이 다르다 — **칩 필터 바는 페이지 가장자리까지 찬다.** `SPage` 에 `padding="none"` 을 주고(§4-1 의 셸이 넘긴다), 바에 `className="border-b"` 로 아래를 긋고(흰 바탕이라 흰 페이지 위에서 경계가 없다 — 선 색은 바가 토큰으로 들고 있어 두께만 준다), 그 아래 툴바+표 블록이 `p-[var(--cmp-pageBody-padding-default)]` 로 같은 토큰의 여백을 잡는다. 코드는 Storybook `Recipes/목록 페이지` 의 "칩 필터" 에 있다.
2212
2225
 
2213
2226
  액션 버튼의 위치가 핵심이다:
2214
2227
 
@@ -2220,44 +2233,120 @@ import { SModalOutlet } from 'sellmate-design-system-react';
2220
2233
  - `fill` 에서는 페이지가 스크롤하지 않으므로 **`scrollEndSpacing` 은 무시된다** — 따로 끄지 않는다 (§2-2).
2221
2234
  - **정렬 가능한 컬럼은 `sortable` 로 준다.** 정렬 상태(`sort`)는 이 페이지가 들고 `onSortChange` 로 받는다 — 조회 조건이라 URL 에 실려야 한다 (§3-4).
2222
2235
 
2236
+ <!-- recipe:src/recipes/ProductListPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
2223
2237
  ```tsx
2238
+ import { useState } from 'react';
2224
2239
  import {
2225
- SButton, SKeyValueTable, STableBar, STable, STag,
2226
- type STableColumn, type SRow, type SKeyValueField,
2240
+ SButton,
2241
+ SKeyValueTable,
2242
+ STable,
2243
+ STableBar,
2244
+ STag,
2245
+ type SDateRangeValue,
2246
+ type SKeyValueField,
2247
+ type SRow,
2248
+ type STableColumn,
2249
+ type STableSort,
2227
2250
  } from 'sellmate-design-system-react';
2251
+ import AppShell from './AppShell';
2252
+ import { STATUS_OPTIONS, removeProducts, useProductList, type ProductQuery } from './api';
2253
+ import { useNavigate } from './router';
2228
2254
 
2229
2255
  /** 필터도 표다 — SKeyValueTable 로 만든다 (서비스 전용 전역 필터가 따로 있는 경우 제외) */
2230
2256
  const filterFields: SKeyValueField[][] = [
2231
2257
  [
2232
- { name: 'status', label: '상태', type: 'select',
2233
- options: { options: STATUS_OPTIONS, valueAsPrimitive: true } },
2234
- { name: 'keyword', label: '검색어', type: 'input',
2235
- options: { placeholder: '상품명 / 상품코드' } },
2258
+ // 조회 조건으로 쓰는 셀렉트·날짜 피커는 clearable — 걸어 둔 조건을 풀 수 있어야 한다 (§3-7-4)
2259
+ {
2260
+ name: 'status',
2261
+ label: '상태',
2262
+ type: 'select',
2263
+ options: { options: STATUS_OPTIONS, valueAsPrimitive: true, clearable: true },
2264
+ },
2265
+ {
2266
+ name: 'keyword',
2267
+ label: '검색어',
2268
+ type: 'input',
2269
+ options: { placeholder: '상품명 / 상품코드' },
2270
+ },
2236
2271
  ],
2237
2272
  [
2238
- { name: 'period', label: '등록일', type: 'date-range-picker', tdColSpan: 3 },
2273
+ {
2274
+ name: 'period',
2275
+ label: '등록일',
2276
+ type: 'date-range-picker',
2277
+ tdColSpan: 3,
2278
+ options: { clearable: true },
2279
+ },
2239
2280
  ],
2240
2281
  ];
2241
2282
 
2283
+ // 정렬·폭은 값의 성격에서 나온다 (§3-4). 모든 컬럼에 폭을 px 로 명시한다
2242
2284
  const columns: STableColumn[] = [
2243
- // 숫자 컬럼은 전부 align: 'right' — §3-4
2244
- { name: 'id', label: 'ID', field: 'id', width: '80px', align: 'right' },
2245
- { name: 'name', label: '상품명', field: 'name' },
2246
- { name: 'stock', label: '재고', field: 'stock', width: '90px', align: 'right',
2247
- format: (v: number) => `${Number(v).toLocaleString()}개` },
2248
- { name: 'price', label: '판매가', field: 'price', width: '120px', align: 'right',
2249
- format: (v: number) => `${Number(v).toLocaleString()}원` },
2250
- // 상태 컬럼은 STag size="sm" — SBadge 색 점을 기본으로 쓰지 않는다
2251
- { name: 'status', label: '상태', field: 'status', width: '100px', align: 'center',
2285
+ // ID·코드는 크기를 비교하는 숫자가 아니라 식별자다 → center. right 는 양을 나타내는 값에만
2286
+ { name: 'id', label: 'ID', field: 'id', width: 80, align: 'center' },
2287
+ // 자유 입력 텍스트 → 기준 폭을 주고, 표에 resizable 을 켜서 사용자가 늘릴 수 있게 한다
2288
+ { name: 'name', label: '상품명', field: 'name', width: 240 },
2289
+ // 값의 크기를 비교하는 컬럼만 sortable — 정렬 상태는 페이지가 들고 있다
2290
+ {
2291
+ name: 'stock',
2292
+ label: '재고',
2293
+ field: 'stock',
2294
+ width: 100,
2295
+ align: 'right',
2296
+ sortable: true,
2297
+ format: (v: unknown) => `${Number(v).toLocaleString()}개`,
2298
+ },
2299
+ {
2300
+ name: 'price',
2301
+ label: '판매가',
2302
+ field: 'price',
2303
+ width: 120,
2304
+ align: 'right',
2305
+ sortable: true,
2306
+ format: (v: unknown) => `${Number(v).toLocaleString()}원`,
2307
+ },
2308
+ // 상태 컬럼은 STag size="sm" — SBadge 색 점을 기본으로 쓰지 않는다.
2309
+ // 컨트롤이 들어가는 컬럼은 contentType: 'control', resizable 표에서는 minWidth 도 함께 (§3-4)
2310
+ {
2311
+ name: 'status',
2312
+ label: '상태',
2313
+ field: 'status',
2314
+ width: 100,
2315
+ minWidth: 100,
2316
+ align: 'center',
2317
+ contentType: 'control',
2252
2318
  render: (row: SRow) =>
2253
- row.status === 'selling'
2254
- ? <STag size="sm" color="green" label="판매중" />
2255
- : <STag size="sm" color="grey" label="판매중지" /> },
2319
+ row.status === 'selling' ? (
2320
+ <STag size="sm" color="green" label="판매중" />
2321
+ ) : (
2322
+ <STag size="sm" color="grey" label="판매중지" />
2323
+ ),
2324
+ },
2256
2325
  ];
2257
2326
 
2258
2327
  export default function ProductListPage() {
2328
+ const navigate = useNavigate();
2329
+ // 편집 중인 필터 값과, 검색을 눌러 실제로 걸린 조회 조건은 따로 든다.
2330
+ // 조회 조건(필터·정렬·페이지)은 실제 앱에서는 URL 쿼리에 싣는다 — 새로고침·공유에 살아남아야 한다
2259
2331
  const [filters, setFilters] = useState<Record<string, unknown>>({});
2332
+ const [query, setQuery] = useState<ProductQuery>({ page: 1, rowsPerPage: 20 });
2260
2333
  const [selected, setSelected] = useState<SRow[]>([]);
2334
+ const { rows, total, lastPage, loading } = useProductList(query);
2335
+
2336
+ const search = () => {
2337
+ setQuery(q => ({
2338
+ ...q,
2339
+ status: (filters.status as string | null) ?? undefined,
2340
+ keywords: filters.keyword ? [String(filters.keyword)] : undefined,
2341
+ period: (filters.period as SDateRangeValue) ?? undefined,
2342
+ page: 1,
2343
+ }));
2344
+ };
2345
+
2346
+ const removeSelected = async () => {
2347
+ await removeProducts(selected.map(row => Number(row.id)));
2348
+ setSelected([]);
2349
+ };
2261
2350
 
2262
2351
  return (
2263
2352
  <AppShell
@@ -2265,7 +2354,15 @@ export default function ProductListPage() {
2265
2354
  variant: 'bar',
2266
2355
  title: '상품 목록',
2267
2356
  // 이 페이지의 주요 액션이 아니라 부가 액션 — slot 은 sm 버튼으로만 채운다
2268
- slot: <SButton size="sm" color="neutral" outline label="이용 가이드" onClick={openGuide} />,
2357
+ slot: (
2358
+ <SButton
2359
+ size="sm"
2360
+ color="neutral"
2361
+ outline
2362
+ label="이용 가이드"
2363
+ onClick={() => navigate('/guide/products')}
2364
+ />
2365
+ ),
2269
2366
  }}
2270
2367
  contentHeight="fill" // 표가 남은 높이를 채우고 페이지네이션이 하단에 고정된다
2271
2368
  >
@@ -2279,10 +2376,11 @@ export default function ProductListPage() {
2279
2376
  fieldWidth="fill"
2280
2377
  search
2281
2378
  onChange={({ values }) => setFilters(values)}
2282
- onSearch={fetchList}
2379
+ onSearch={search}
2283
2380
  />
2284
2381
 
2285
- {/* 툴바+표 = 한 블록. 사이를 벌리지 않는다 (§2-0) */}
2382
+ {/* 툴바+표 = 한 블록. 사이를 벌리지 않는다 (§2-0).
2383
+ 이 div 도 높이 사슬의 한 칸이라 min-h-0 flex-1 이 필요하다 */}
2286
2384
  <div className="flex min-h-0 flex-1 flex-col">
2287
2385
  {/* 툴바 — 좌: 건수 + (구분선 자동) + 선택 액션 / 우: 주요 액션
2288
2386
  맞닿는 가로선은 표가 그리므로 아래 테두리는 지운다 */}
@@ -2292,25 +2390,37 @@ export default function ProductListPage() {
2292
2390
  selected={selected.length}
2293
2391
  actions={
2294
2392
  /* 선택 항목 단위 파괴 액션 → danger outline (§3-5-3) */
2295
- <SButton size="sm" color="danger" outline label="선택 삭제"
2296
- disabled={!selected.length} onClick={removeSelected} />
2393
+ <SButton
2394
+ size="sm"
2395
+ color="danger"
2396
+ outline
2397
+ label="선택 삭제"
2398
+ disabled={!selected.length}
2399
+ onClick={removeSelected}
2400
+ />
2297
2401
  }
2298
2402
  rightActions={
2299
2403
  /* 이 페이지의 유일한 primary 채움 (§3-5-1) */
2300
- <SButton size="sm" label="상품 등록" onClick={goCreate} />
2404
+ <SButton size="sm" label="상품 등록" onClick={() => navigate('/products/new')} />
2301
2405
  }
2302
2406
  />
2303
2407
 
2304
- {/* 남은 높이를 채우고 본문만 스크롤한다 — 페이지네이션 바는 표 안에서 하단 고정 */}
2408
+ {/* 남은 높이를 채우고 본문만 스크롤한다 — 페이지네이션 바는 표 안에서 하단 고정.
2409
+ dense 는 시작 밀도이자 밀도 토글의 스위치 — onDenseChange 값을 되돌려 넣지 않는다 (§4-2) */}
2305
2410
  <STable
2306
2411
  className="min-h-0 flex-1"
2307
2412
  columns={columns}
2308
2413
  rows={rows}
2309
2414
  rowKey="id"
2415
+ dense
2416
+ resizable
2310
2417
  selectable
2311
2418
  selected={selected}
2312
2419
  onSelectedChange={setSelected}
2313
- pagination={{ currentPage, lastPage }}
2420
+ sort={query.sort}
2421
+ onSortChange={(sort: STableSort | null) => setQuery(q => ({ ...q, sort, page: 1 }))}
2422
+ pagination={{ page: query.page, rowsPerPage: query.rowsPerPage, lastPage }}
2423
+ onPageChange={page => setQuery(q => ({ ...q, page }))}
2314
2424
  loading={loading}
2315
2425
  />
2316
2426
  </div>
@@ -2319,6 +2429,7 @@ export default function ProductListPage() {
2319
2429
  );
2320
2430
  }
2321
2431
  ```
2432
+ <!-- /recipe -->
2322
2433
 
2323
2434
  #### 한 화면에 더 많은 행을 — `dense` 와 밀도 토글
2324
2435
 
@@ -2328,7 +2439,7 @@ export default function ProductListPage() {
2328
2439
 
2329
2440
  ```tsx
2330
2441
  // 좁게 시작하고, 사용자가 바꾸는 밀도는 표가 알아서 들고 간다
2331
- <STable dense showRowsPerPageSelect pagination={{ currentPage, lastPage }} />;
2442
+ <STable dense showRowsPerPageSelect pagination={{ page, rowsPerPage, lastPage }} />;
2332
2443
 
2333
2444
  // 사용자가 고른 밀도를 다음 방문에도 남기려는 화면만 받아서 저장한다.
2334
2445
  // 저장한 값은 시작 밀도로만 돌려준다 — 되돌려 넣지 않는다
@@ -2338,7 +2449,7 @@ const [initialDense] = useState(() => loadPref('list.dense', true));
2338
2449
  dense={initialDense}
2339
2450
  onDenseChange={next => savePref('list.dense', next)}
2340
2451
  showRowsPerPageSelect
2341
- pagination={{ currentPage, lastPage }}
2452
+ pagination={{ page, rowsPerPage, lastPage }}
2342
2453
  />;
2343
2454
  ```
2344
2455
 
@@ -2403,61 +2514,113 @@ const [initialDense] = useState(() => loadPref('list.dense', true));
2403
2514
 
2404
2515
  **병합으로 찬 자리는 배열 순서가 아니라 실제 열 위치로 판정한다.** 그러니 아래 행의 필드를 병합된 열에 맞추려고 자리 채우기용 빈 필드를 끼우지 않는다 — 앞 필드에 `tdColSpan` 이나 `hideTh` 가 있어도 남은 필드가 알아서 빈 열부터 놓인다.
2405
2516
 
2517
+ <!-- recipe:src/recipes/ProductCreatePage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
2406
2518
  ```tsx
2519
+ import { useState } from 'react';
2407
2520
  import {
2408
- SForm, SKeyValueTable, SButton, SCheckbox,
2409
- type SFormHandle, type SKeyValueField, type Rule,
2521
+ SButton,
2522
+ SCheckbox,
2523
+ SForm,
2524
+ SKeyValueTable,
2525
+ type Rule,
2526
+ type SKeyValueField,
2410
2527
  } from 'sellmate-design-system-react';
2528
+ import AppShell from './AppShell';
2529
+ import { CATEGORY_OPTIONS, saveProduct } from './api';
2530
+ import { useNavigate } from './router';
2411
2531
 
2412
- const required = (msg: string): Rule => v =>
2413
- v != null && String(v).trim() !== '' ? true : msg;
2532
+ const required =
2533
+ (msg: string): Rule =>
2534
+ v =>
2535
+ v != null && String(v).trim() !== '' ? true : msg;
2414
2536
 
2537
+ /** 필드를 div 로 나열하지 않는다 — SKeyValueTable 의 행으로 구성한다 */
2415
2538
  const fields: SKeyValueField[][] = [
2416
2539
  [
2417
- { name: 'name', label: '상품명', required: true, type: 'input',
2418
- options: { placeholder: '상품명 입력', rules: [required('상품명을 입력해 주세요.')] } },
2540
+ {
2541
+ name: 'name',
2542
+ label: '상품명',
2543
+ required: true,
2544
+ type: 'input',
2545
+ options: { placeholder: '상품명 입력', rules: [required('상품명을 입력해 주세요.')] },
2546
+ },
2419
2547
  { name: 'code', label: '상품코드', type: 'input', options: { placeholder: '자동 생성' } },
2420
2548
  ],
2421
2549
  [
2422
- { name: 'category', label: '카테고리', required: true, type: 'select',
2423
- options: { options: CATEGORY_OPTIONS, valueAsPrimitive: true,
2424
- rules: [required('카테고리를 선택해 주세요.')] } },
2550
+ {
2551
+ name: 'category',
2552
+ label: '카테고리',
2553
+ required: true,
2554
+ type: 'select',
2555
+ options: {
2556
+ options: CATEGORY_OPTIONS,
2557
+ valueAsPrimitive: true,
2558
+ rules: [required('카테고리를 선택해 주세요.')],
2559
+ },
2560
+ },
2425
2561
  { name: 'price', label: '판매가', type: 'number-input' },
2426
2562
  ],
2427
2563
  [
2428
- { name: 'memo', label: '메모', type: 'textarea', tdColSpan: 3,
2429
- helpText: ['내부 관리용 메모입니다.'] },
2564
+ // 가장 긴 행(필드 2개 = 4칸)에 맞춰 마지막 필드가 남는 칸을 채운다
2565
+ {
2566
+ name: 'memo',
2567
+ label: '메모',
2568
+ type: 'textarea',
2569
+ tdColSpan: 3,
2570
+ helpText: ['내부 관리용 메모입니다.'],
2571
+ },
2430
2572
  ],
2431
2573
  ];
2432
2574
 
2433
2575
  export default function ProductCreatePage() {
2434
- const formRef = useRef<SFormHandle>(null);
2576
+ const navigate = useNavigate();
2435
2577
  const [values, setValues] = useState<Record<string, unknown>>({});
2578
+ const [keepOpen, setKeepOpen] = useState(false);
2579
+
2580
+ // SForm 이 하위 컨트롤의 rules 를 모아 submit 때 일괄 검증한다 — 통과했을 때만 불린다
2581
+ const save = async () => {
2582
+ await saveProduct(values);
2583
+ if (keepOpen) setValues({});
2584
+ else navigate('/products');
2585
+ };
2436
2586
 
2437
2587
  return (
2438
- <AppShell header={{ variant: 'bar', title: '상품 등록', onBack: goBack }}>
2439
- <div className="flex flex-col gap-sd-12">
2440
- <SForm ref={formRef} formClassName="flex flex-col gap-sd-12" onSubmit={save}>
2441
- <SKeyValueTable
2442
- fields={fields}
2443
- values={values}
2444
- onChange={({ values }) => setValues(values)}
2445
- />
2588
+ <AppShell
2589
+ header={{ variant: 'bar', title: '상품 등록', onBack: () => navigate(-1) }}
2590
+ contentHeight="fill" // 폼 페이지도 fill 이 기본 — 폼이 길어 실제로 스크롤될 때만 auto + scrollEndSpacing
2591
+ >
2592
+ <SForm formClassName="flex flex-col gap-sd-12" onSubmit={save}>
2593
+ <SKeyValueTable
2594
+ fields={fields}
2595
+ values={values}
2596
+ onChange={({ values }) => setValues(values)}
2597
+ />
2446
2598
 
2447
- {/* 하단 버튼은 양끝으로 벌린다. 부가 요소(체크박스 등)는 저장 바로 왼쪽 */}
2448
- <div className="flex items-center justify-between">
2449
- <SButton type="button" color="neutral" outline label="취소" onClick={goBack} />
2450
- <div className="flex items-center gap-sd-8">
2451
- <SCheckbox label="계속 등록하기" value={keepOpen} onValueChange={v => setKeepOpen(v as boolean)} />
2452
- <SButton type="submit" label="저장" />
2453
- </div>
2599
+ {/* 버튼 순서는 모든 화면에서 같다 — 취소는 왼쪽 끝, 저장은 오른쪽 끝.
2600
+ 부가 요소(체크박스 등)는 저장 바로 왼쪽 */}
2601
+ <div className="flex items-center justify-between">
2602
+ <SButton
2603
+ type="button"
2604
+ color="neutral"
2605
+ outline
2606
+ label="취소"
2607
+ onClick={() => navigate(-1)}
2608
+ />
2609
+ <div className="flex items-center gap-sd-8">
2610
+ <SCheckbox
2611
+ label="계속 등록하기"
2612
+ value={keepOpen}
2613
+ onValueChange={v => setKeepOpen(v as boolean)}
2614
+ />
2615
+ <SButton type="submit" label="저장" />
2454
2616
  </div>
2455
- </SForm>
2456
- </div>
2617
+ </div>
2618
+ </SForm>
2457
2619
  </AppShell>
2458
2620
  );
2459
2621
  }
2460
2622
  ```
2623
+ <!-- /recipe -->
2461
2624
 
2462
2625
  ### 4-4. 상세(조회) 페이지
2463
2626
 
@@ -2465,7 +2628,7 @@ export default function ProductCreatePage() {
2465
2628
 
2466
2629
  - 조회 값은 `type: 'text'` 행으로 표시한다. **상태·분류 태그도 별도 영역이 아니라 표의 한 행**으로 넣는다 (`render` 에 `STag`).
2467
2630
  - 행이 많아지면 **유형별로 섹션을 나누고, 각 섹션을 `SSectionHeaderCard` 로 감싼다.**
2468
- 섹션 제목은 `title` prop 으로, 바디 여백은 `padding` prop 으로 준다.
2631
+ 섹션 제목은 `title` prop 으로, 바디 여백은 `padding` prop 으로 준다. **카드 안이 `SKeyValueTable` 하나뿐이면 `padding="none"` 으로 가장자리까지 채우고 표는 `bordered={false} radius="useTop"`** 으로 자기 테두리를 끈다 (§2-2 "카드 가장자리까지 채우는 표").
2469
2632
  - **한 화면에 행이 많아 훑어보기가 목적이면 `SKeyValueTable` 에 `dense` 를 켠다** — 셀 상하 여백과
2470
2633
  최소 높이가 함께 내려가 행이 좁아진다. 다만 **읽기 전용(`text`) 행에서만 실제로 좁아진다**:
2471
2634
  폼 필드가 든 행은 필드 자체의 높이가 행 높이를 정하므로 거의 변하지 않는다. 그래서 필터(§4-2)나
@@ -2474,23 +2637,19 @@ export default function ProductCreatePage() {
2474
2637
  - **상세 페이지도 `contentHeight="fill"` 이 기본이다**(§2-2). 섹션이 많아 페이지가 실제로 스크롤되는 화면에서만 `auto` + `scrollEndSpacing` 을 켠다.
2475
2638
  - **섹션마다 독립 인라인 폼이 있는 형태**도 상세 페이지의 변형이다. 섹션 안에서 바로 수정·저장하게 하는 화면인데, 이때 버튼 강조는 **섹션 단위가 아니라 페이지 단위로 판단한다** — §3-5-1 의 "`secondary` 연속 배치 금지"는 섹션이 다르면 적용되지 않는다. 그 섹션 안에서 닫히는 저장·취소는 `SSectionHeaderCard` 의 `button`·`footerLeft` 로 넘긴다 (§3-7-8). 아래 예처럼 **페이지 전체를 확정하는 버튼은 카드 밖 하단 줄**에 둔다 — 둘을 섞지 않는다.
2476
2639
 
2640
+ <!-- recipe:src/recipes/ProductDetailPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
2477
2641
  ```tsx
2478
2642
  import {
2479
- SSectionHeaderCard, SKeyValueTable, SButton, STag, type SKeyValueField,
2643
+ SButton,
2644
+ SKeyValueTable,
2645
+ SModal,
2646
+ SSectionHeaderCard,
2647
+ STag,
2648
+ type SKeyValueField,
2480
2649
  } from 'sellmate-design-system-react';
2481
-
2482
- const basicFields: SKeyValueField[][] = [
2483
- [
2484
- { name: 'code', label: '상품코드', type: 'text' },
2485
- { name: 'createdAt', label: '등록일', type: 'text' },
2486
- ],
2487
- [
2488
- { name: 'category', label: '카테고리', type: 'text' },
2489
- // 상태 태그도 표의 한 행으로 표현한다
2490
- { name: 'status', label: '상태',
2491
- render: <STag size="sm" color="green" label="판매중" /> },
2492
- ],
2493
- ];
2650
+ import AppShell from './AppShell';
2651
+ import { removeProducts, useProduct } from './api';
2652
+ import { useNavigate, useParams } from './router';
2494
2653
 
2495
2654
  const priceFields: SKeyValueField[][] = [
2496
2655
  [
@@ -2500,41 +2659,96 @@ const priceFields: SKeyValueField[][] = [
2500
2659
  ];
2501
2660
 
2502
2661
  export default function ProductDetailPage() {
2662
+ const navigate = useNavigate();
2663
+ const { id } = useParams();
2664
+ const product = useProduct(Number(id));
2665
+
2666
+ // 되돌릴 수 없는 액션은 실행 여부만 확정받는다 — 직접 만든 confirm 대신 SModal.confirm (§3-3-1)
2667
+ const confirmDelete = () =>
2668
+ SModal.confirm({
2669
+ type: 'negative',
2670
+ modalTitle: '상품을 삭제할까요?',
2671
+ topMessage: ['삭제된 상품은 복구할 수 없습니다.'],
2672
+ mainButtonLabel: '삭제',
2673
+ }).onOk(async () => {
2674
+ await removeProducts([product.id]);
2675
+ navigate('/products');
2676
+ });
2677
+
2678
+ // 조회 값은 type: 'text' 행으로 보인다.
2679
+ // 상태 태그도 별도 영역이 아니라 표의 한 행이다 — 값에 따라 달라지므로 컴포넌트 안에서 만든다
2680
+ const basicFields: SKeyValueField[][] = [
2681
+ [
2682
+ { name: 'code', label: '상품코드', type: 'text' },
2683
+ { name: 'createdAt', label: '등록일', type: 'text' },
2684
+ ],
2685
+ [
2686
+ { name: 'category', label: '카테고리', type: 'text' },
2687
+ {
2688
+ name: 'status',
2689
+ label: '상태',
2690
+ render:
2691
+ product.status === 'selling' ? (
2692
+ <STag size="sm" color="green" label="판매중" />
2693
+ ) : (
2694
+ <STag size="sm" color="grey" label="판매중지" />
2695
+ ),
2696
+ },
2697
+ ],
2698
+ ];
2699
+
2503
2700
  return (
2504
2701
  // 목록에서 들어온 상세 페이지 — onBack 으로 뒤로가기를 준다
2505
- <AppShell header={{ variant: 'bar', title: '클래식 셔츠', onBack: goList }}>
2702
+ <AppShell
2703
+ header={{ variant: 'bar', title: product.name, onBack: () => navigate('/products') }}
2704
+ contentHeight="fill" // 섹션이 많아 실제로 스크롤될 때만 auto + scrollEndSpacing
2705
+ >
2506
2706
  <div className="flex flex-col gap-sd-12">
2507
- <SSectionHeaderCard title="기본 정보" marker thickness="accent">
2508
- <SKeyValueTable fields={basicFields} values={product} />
2707
+ {/* 행이 많아지면 유형별로 섹션을 나눈다. 카드가 둘 이상이라 marker 를 켠다 —
2708
+ 한 페이지 안의 카드 강조(marker·thickness)는 전부 같게 간다 (§4-5).
2709
+ 카드 안이 표 하나뿐이면 padding="none" 으로 가장자리까지 채우고, 표는 bordered={false} 로
2710
+ 자기 테두리를 끄고 radius="useTop" 으로 위 모서리를 죽인다 — 카드가 테두리·아래 라운드를 그린다 (§2-2) */}
2711
+ <SSectionHeaderCard title="기본 정보" marker padding="none">
2712
+ <SKeyValueTable fields={basicFields} values={product} bordered={false} radius="useTop" />
2509
2713
  </SSectionHeaderCard>
2510
2714
 
2511
- {/* 헤더 우측에 액션이 필요하면 slot 을 쓴다 */}
2715
+ {/* 헤더 우측에 액션이 필요하면 slot 을 쓴다 — 이동 액션이라 outline (§3-5-1) */}
2512
2716
  <SSectionHeaderCard
2513
2717
  title="가격 정보"
2514
2718
  marker
2515
2719
  helpText={['부가세 포함 금액입니다.']}
2516
- slot={<SButton size="sm" color="secondary" label="이력" onClick={openHistory} />}
2720
+ padding="none"
2721
+ slot={
2722
+ <SButton
2723
+ size="sm"
2724
+ color="neutral"
2725
+ outline
2726
+ label="이력"
2727
+ onClick={() => navigate(`/products/${id}/price-history`)}
2728
+ />
2729
+ }
2517
2730
  >
2518
- <SKeyValueTable fields={priceFields} values={product} />
2731
+ <SKeyValueTable fields={priceFields} values={product} bordered={false} radius="useTop" />
2519
2732
  </SSectionHeaderCard>
2520
2733
 
2521
- {/* 액션은 하단 — 목록(되돌리기)은 왼쪽 끝, 실행 액션은 오른쪽 끝 */}
2734
+ {/* 액션은 하단 — 그룹을 모으지 않고 양끝으로 벌린다 (§3-5).
2735
+ 목록으로 돌아가기는 헤더의 onBack 이 이미 하므로 되돌리는 버튼을 따로 두지 않는다.
2736
+ 되돌리는 액션이 없어도 양끝 분리는 그대로다 — 위계가 낮은 삭제(danger outline)가 왼쪽 끝,
2737
+ 가장 강한 실행 액션인 수정이 오른쪽 끝 */}
2522
2738
  <div className="flex items-center justify-between">
2523
- <SButton color="neutral" outline label="목록" onClick={goList} />
2524
- <div className="flex items-center gap-sd-8">
2525
- <SButton color="danger" outline label="삭제" onClick={confirmDelete} />
2526
- <SButton label="수정" onClick={goEdit} />
2527
- </div>
2739
+ <SButton color="danger" outline label="삭제" onClick={confirmDelete} />
2740
+ <SButton label="수정" onClick={() => navigate(`/products/${id}/edit`)} />
2528
2741
  </div>
2529
2742
  </div>
2530
2743
  </AppShell>
2531
2744
  );
2532
2745
  }
2533
2746
  ```
2747
+ <!-- /recipe -->
2534
2748
 
2535
2749
  ### 4-5. 섹션 카드 — SSectionHeaderCard
2536
2750
 
2537
- 폼 페이지에서도 입력 항목이 많으면 유형별로 `SSectionHeaderCard` 로 나눈다. 주요 옵션:
2751
+ 폼 페이지에서도 입력 항목이 많으면 유형별로 `SSectionHeaderCard` 로 나눈다. **카드 안이 `SKeyValueTable` 하나뿐이면 `padding="none"` 으로 가장자리까지 채우고, 표는 `bordered={false} radius="useTop"` 으로 자기 테두리를 끈다** (§2-2 "카드 가장자리까지 채우는 표"). 코드는 Storybook `Recipes/폼 페이지` 의 "섹션 나눔" 에 있다. 주요 옵션:
2538
2752
 
2539
2753
  | Prop (Header) | 용도 |
2540
2754
  | --- | --- |
@@ -2577,24 +2791,48 @@ export default function ProductDetailPage() {
2577
2791
 
2578
2792
  로그인 전이라 앱 셸이 아직 없다. `SLayout`·`SGnb`·`SPage` 가 없고, 회색 바탕 위에 카드 하나만 선다. (앱 셸 없이 서는 화면은 이것과 팝업 라우트(§4-7) 둘뿐이다.)
2579
2793
 
2794
+ <!-- recipe:src/recipes/LoginPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
2580
2795
  ```tsx
2581
- <div className="flex h-screen items-stretch justify-center bg-(--sys-color-bg-neutralLight) p-sd-24">
2582
- <SLoginCard
2583
- product="AI assistant"
2584
- title="셀메이트 AI 어시스턴트와 함께 자료 찾는 시간은 줄이고, 업무는 빠르게!"
2585
- description="(사내 답변은 사내 문서를 기반으로 답변합니다.)"
2586
- onLogin={signIn}
2587
- footerNote="계정이 없다면 담당자에게 문의를 요청해 주세요."
2588
- bottomAction={
2589
- <STextLink label="관리자 로그인" icon="settingOutline" rightArrow="chevron" onClick={goAdmin} />
2590
- }
2591
- />
2592
- </div>
2796
+ import { SLoginCard, STextLink } from 'sellmate-design-system-react';
2797
+ import { useNavigate } from './router';
2798
+
2799
+ /**
2800
+ * 로그인 전이라 앱 셸이 없다 — AppShell 을 쓰지 않고 회색 바탕 위에 카드 하나만 선다.
2801
+ * 카드는 화면을 잡지 않으므로 바깥 한 겹은 앱이 만든다. 그 한 겹이 하는 일은 셋뿐이다:
2802
+ * 바탕·여백 / items-stretch 로 카드가 높이를 채우게 / justify-center 로 가로 가운데
2803
+ */
2804
+ export default function LoginPage() {
2805
+ const navigate = useNavigate();
2806
+
2807
+ return (
2808
+ // 여백은 카드가 최소 폭을 지킬 수 있는 값으로 둔다 — 카드 폭은 className 으로 박지 않는다
2809
+ <div className="flex h-screen items-stretch justify-center bg-bg-neutralLight p-sd-24">
2810
+ <SLoginCard
2811
+ product="AI assistant"
2812
+ title="셀메이트 AI 어시스턴트와 함께 자료 찾는 시간은 줄이고, 업무는 빠르게!"
2813
+ description="(사내 답변은 사내 문서를 기반으로 답변합니다.)"
2814
+ // 로그인 버튼은 카드가 그린다 — 동작만 넘긴다
2815
+ onLogin={() => navigate('/auth/sso')}
2816
+ footerNote="계정이 없다면 담당자에게 문의를 요청해 주세요."
2817
+ // 로그인과 성격이 다른 보조 진입점만 — STextLink 하나. 버튼을 세우지 않는다
2818
+ bottomAction={
2819
+ <STextLink
2820
+ label="관리자 로그인"
2821
+ icon="settingOutline"
2822
+ rightArrow="chevron"
2823
+ onClick={() => navigate('/admin/login')}
2824
+ />
2825
+ }
2826
+ />
2827
+ </div>
2828
+ );
2829
+ }
2593
2830
  ```
2831
+ <!-- /recipe -->
2594
2832
 
2595
2833
  **바깥 한 겹은 앱이 만든다.** 카드는 화면을 잡지 않는다 — 덕분에 로그인 화면 밖(세션 만료 재인증 등)에서도 같은 카드를 그대로 쓴다. 그 한 겹은 위 세 가지만 하면 되고, 그 이상은 하지 않는다.
2596
2834
 
2597
- - 바탕은 `bg-(--sys-color-bg-neutralLight)`, 여백은 `p-sd-24`. **여백은 카드가 최소 폭(368px)을 지킬 수 있는 값으로 둔다** — 좌우 합이 창 폭에서 368px 을 남기지 못하면 카드가 넘친다
2835
+ - 바탕은 `bg-bg-neutralLight`, 여백은 `p-sd-24`. **여백은 카드가 최소 폭(368px)을 지킬 수 있는 값으로 둔다** — 좌우 합이 창 폭에서 368px 을 남기지 못하면 카드가 넘친다
2598
2836
  - `items-stretch` — 카드가 남은 높이를 채운다. `items-center` 로 두면 카드가 내용 높이로 줄어든다
2599
2837
  - `justify-center` — 카드를 가로 가운데에
2600
2838
 
@@ -2621,9 +2859,23 @@ export default function ProductDetailPage() {
2621
2859
 
2622
2860
  앱 셸 없이 서는 또 하나의 화면이다. 별도 브라우저 창으로 열리는 전용 라우트이므로 `SLayout`·`SGnb`·`SPage` 가 없고, 창을 통째로 `SPopup` 하나가 채운다. 무엇을 팝업으로 열지는 §3-3-1·§3-3-3 에서 고르고, 여기서는 그 라우트의 골격만 다룬다.
2623
2861
 
2862
+ <!-- recipe:src/recipes/TransferOrderPopupPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
2624
2863
  ```tsx
2864
+ import { useState } from 'react';
2865
+ import {
2866
+ SKeyValueTable,
2867
+ SPopup,
2868
+ SSectionHeaderCard,
2869
+ STable,
2870
+ STabs,
2871
+ type SKeyValueField,
2872
+ type STableColumn,
2873
+ } from 'sellmate-design-system-react';
2874
+ import { ORDER_TABS, useTransferOrder } from './api';
2875
+ import { useParams } from './router';
2876
+
2625
2877
  // 1) 목록에서 별도 창을 연다 — 창 크기 = 콘텐츠 크기
2626
- function openDetailPopup(orderId: string) {
2878
+ export function openTransferOrderPopup(orderId: string) {
2627
2879
  window.open(
2628
2880
  `${window.location.origin}/popup/transfer-orders/${orderId}`,
2629
2881
  `transfer-order-${orderId}`,
@@ -2631,21 +2883,55 @@ function openDetailPopup(orderId: string) {
2631
2883
  );
2632
2884
  }
2633
2885
 
2634
- // 2) 그 라우트의 루트는 SPopup 하나다 (조회만 → 푸터 없음)
2886
+ const summaryFields: SKeyValueField[][] = [
2887
+ [
2888
+ { name: 'from', label: '출발 창고', type: 'text' },
2889
+ { name: 'to', label: '도착 창고', type: 'text' },
2890
+ { name: 'requestedAt', label: '요청일', type: 'text' },
2891
+ ],
2892
+ ];
2893
+
2894
+ // 정렬·폭은 값의 성격에서 나온다 (§3-4) — 코드·일시·닫힌 집합은 center, 양은 right, 자유 텍스트는 기준 폭
2895
+ const itemColumns: STableColumn[] = [
2896
+ { name: 'sku', label: '품목 코드', field: 'sku', width: 140, align: 'center' },
2897
+ { name: 'name', label: '품목명', field: 'name', width: 240 },
2898
+ { name: 'qty', label: '수량', field: 'qty', width: 100, align: 'right' },
2899
+ ];
2900
+
2901
+ const historyColumns: STableColumn[] = [
2902
+ { name: 'at', label: '일시', field: 'at', width: 160, align: 'center' },
2903
+ // 처리 종류는 닫힌 값 집합(요청·승인 …) → center
2904
+ { name: 'action', label: '처리', field: 'action', width: 100, align: 'center' },
2905
+ { name: 'by', label: '처리자', field: 'by', width: 120 },
2906
+ ];
2907
+
2908
+ // 2) 그 라우트의 루트는 SPopup 하나다 — 앱 셸(SLayout·SGnb·SPage)이 없다. 조회만 하므로 푸터도 없다
2635
2909
  export default function TransferOrderPopupPage() {
2910
+ const { orderId } = useParams();
2911
+ const order = useTransferOrder(orderId);
2912
+ const [tab, setTab] = useState('items');
2913
+
2636
2914
  return (
2637
2915
  // h-screen 으로 창 높이를 잡는다 — 이게 없으면 본문이 창 밖으로 자란다
2638
2916
  <SPopup className="h-screen" popupTitle="이동 오더 상세">
2639
2917
  {/* 본문 패딩은 SPopup 이 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다 */}
2640
2918
  <div className="flex flex-col gap-sd-12">
2641
- <SSectionHeaderCard>…</SSectionHeaderCard>
2642
- <STabs value={tab} tabs={TABS} onValueChange={setTab} />
2643
- <STable columns={columns} rows={rows} rowKey="id" />
2919
+ {/* 카드 안이 표 하나뿐이라 padding="none" 으로 가장자리까지 채운다 — 표는 자기 테두리를 끄고 위 모서리를 죽인다 (§2-2) */}
2920
+ <SSectionHeaderCard title={order.orderId} padding="none">
2921
+ <SKeyValueTable fields={summaryFields} values={order} bordered={false} radius="useTop" />
2922
+ </SSectionHeaderCard>
2923
+ <STabs value={tab} tabs={ORDER_TABS} onValueChange={setTab} />
2924
+ {tab === 'items' ? (
2925
+ <STable columns={itemColumns} rows={order.items} rowKey="id" resizable />
2926
+ ) : (
2927
+ <STable columns={historyColumns} rows={order.history} rowKey="id" resizable />
2928
+ )}
2644
2929
  </div>
2645
2930
  </SPopup>
2646
2931
  );
2647
2932
  }
2648
2933
  ```
2934
+ <!-- /recipe -->
2649
2935
 
2650
2936
  **높이는 라우트가 준다 — `SPopup` 에 `h-screen` 을 준다.** `SPopup` 자체는 부모 높이를 채우도록만 되어 있어서, 부모(라우트 루트·`body`)가 높이를 정해 주지 않으면 100% 가 풀려 본문이 창 밖으로 자란다. 그러면 헤더·푸터가 위아래로 밀려 창 안에 보이지 않는다. `h-screen` 이면 `html`·`body`·마운트 루트에 전역 CSS 를 걸지 않고도 창 높이가 바로 들어온다. (`body` 여백은 0 이어야 한다 — Tailwind preflight 가 이미 0 으로 만든다.)
2651
2937
 
@@ -2653,28 +2939,96 @@ export default function TransferOrderPopupPage() {
2653
2939
 
2654
2940
  **표를 담으면 본문이 아니라 표가 스크롤한다.** 본문이 통째로 스크롤되면 표 헤더와 페이지네이션 바가 위아래로 밀려 사라진다. `SPopup` 의 본문은 이미 남은 높이를 잡고 있으므로, 본문 루트에 `h-full min-h-0` 으로 세로 축을 잇고 표에 `min-h-0 flex-1` 을 준다. **툴바+표를 묶은 `div` 도 사슬의 한 칸이라 거기에도 `min-h-0 flex-1` 이 필요하다** (§4-2 목록 페이지와 같은 사슬이다 — 한 칸만 끊겨도 표가 높이를 못 잡는데 실패가 조용하다).
2655
2941
 
2942
+ <!-- recipe:src/recipes/ExcelFilePopupPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
2656
2943
  ```tsx
2657
- <SPopup
2658
- className="h-screen"
2659
- popupTitle="엑셀 파일 관리"
2660
- showFooter // 확정할 작업이 있을 때만 (§3-3-3)
2661
- submitButton={{ label: '저장' }}
2662
- onSubmit={save}
2663
- >
2664
- <div className="flex h-full min-h-0 flex-col gap-sd-12">
2665
- <SKeyValueTable fields={filterFields} values={filters} search
2666
- onChange={({ values }) => setFilters(values)} onSearch={fetchList} />
2944
+ import { useState } from 'react';
2945
+ import {
2946
+ SPopup,
2947
+ SSearchInput,
2948
+ STable,
2949
+ STableBar,
2950
+ type SRow,
2951
+ type STableColumn,
2952
+ } from 'sellmate-design-system-react';
2953
+ import { applyExcelFiles, useExcelFiles } from './api';
2667
2954
 
2668
- {/* 툴바+표 = 한 블록. 이 div 가 사슬의 한 칸이다 (§2-0) */}
2669
- <div className="flex min-h-0 flex-1 flex-col">
2670
- <STableBar className="border-b-0" total={total} />
2671
- {/* 표만 자기 안에서 스크롤한다 — 필터·툴바·푸터는 늘 보인다 */}
2672
- <STable className="min-h-0 flex-1" columns={columns} rows={rows} rowKey="id"
2673
- pagination={{ currentPage, lastPage }} />
2674
- </div>
2675
- </div>
2676
- </SPopup>
2955
+ // 정렬·폭은 값의 성격에서 나온다 (§3-4) — 자유 텍스트는 기준 폭 + resizable, 일자는 center, 양은 right
2956
+ const columns: STableColumn[] = [
2957
+ { name: 'fileName', label: '파일명', field: 'fileName', width: 320 },
2958
+ { name: 'uploadedAt', label: '업로드일', field: 'uploadedAt', width: 120, align: 'center' },
2959
+ {
2960
+ name: 'rows',
2961
+ label: '행 수',
2962
+ field: 'rows',
2963
+ width: 100,
2964
+ align: 'right',
2965
+ format: (v: unknown) => Number(v).toLocaleString(),
2966
+ },
2967
+ ];
2968
+
2969
+ /**
2970
+ * 표를 담은 팝업 — 본문이 아니라 표가 스크롤한다. 툴바·푸터는 늘 보인다.
2971
+ * SPopup 의 본문은 이미 남은 높이를 잡고 있으므로, 본문 루트부터 표까지 세로 축을 잇는다 (§4-2 와 같은 사슬).
2972
+ */
2973
+ export default function ExcelFilePopupPage() {
2974
+ // 입력 중인 검색어와 Enter 로 확정된 조회 조건은 따로 든다
2975
+ const [keyword, setKeyword] = useState('');
2976
+ const [query, setQuery] = useState({ keyword: '', page: 1, rowsPerPage: 20 });
2977
+ const [selected, setSelected] = useState<SRow[]>([]);
2978
+ const { rows, total, lastPage } = useExcelFiles(query);
2979
+
2980
+ const save = () => applyExcelFiles(selected.map(row => Number(row.id)));
2981
+
2982
+ return (
2983
+ <SPopup
2984
+ className="h-screen"
2985
+ popupTitle="엑셀 파일 관리"
2986
+ showFooter // 확정할 작업이 있을 때만 (§3-3-3)
2987
+ submitButton={{ label: '저장', disabled: !selected.length }}
2988
+ onSubmit={save}
2989
+ >
2990
+ {/* 툴바+표 = 한 블록이자 본문 루트. h-full min-h-0 → 표의 min-h-0 flex-1 로 세로 축이 이어진다.
2991
+ 필터 표가 위에 더 서면 이 블록을 한 겹 감싸고 여기에 min-h-0 flex-1 을 준다 (§4-2 와 같은 사슬) */}
2992
+ <div className="flex h-full min-h-0 flex-col">
2993
+ {/* 조건이 검색어 하나뿐이면 필터 표를 세우지 않는다 — 목록을 좁히기만 하는 검색어는
2994
+ 폼 필드가 아니라 SSearchInput 이다 (§3-7-1). 툴바 우측에 둔다 */}
2995
+ <STableBar
2996
+ className="border-b-0"
2997
+ total={total}
2998
+ selected={selected.length}
2999
+ rightActions={
3000
+ <SSearchInput
3001
+ value={keyword}
3002
+ onValueChange={value => {
3003
+ setKeyword(value);
3004
+ // 지우기(×)는 값만 비우고 onSearch 를 부르지 않는다 — 걸려 있던 조건도 여기서 함께 푼다
3005
+ if (!value) setQuery(q => ({ ...q, keyword: '', page: 1 }));
3006
+ }}
3007
+ onSearch={value => setQuery(q => ({ ...q, keyword: value, page: 1 }))}
3008
+ placeholder="파일명 검색"
3009
+ width="md"
3010
+ clearable
3011
+ />
3012
+ }
3013
+ />
3014
+ <STable
3015
+ className="min-h-0 flex-1"
3016
+ columns={columns}
3017
+ rows={rows}
3018
+ rowKey="id"
3019
+ resizable
3020
+ selectable
3021
+ selected={selected}
3022
+ onSelectedChange={setSelected}
3023
+ pagination={{ page: query.page, rowsPerPage: query.rowsPerPage, lastPage }}
3024
+ onPageChange={page => setQuery(q => ({ ...q, page }))}
3025
+ />
3026
+ </div>
3027
+ </SPopup>
3028
+ );
3029
+ }
2677
3030
  ```
3031
+ <!-- /recipe -->
2678
3032
 
2679
3033
  조회만 하고 표가 없는 팝업은 이 사슬이 필요 없다 — 본문이 통째로 스크롤되어도 헤더는 고정이고 밀려날 푸터가 없다.
2680
3034
 
@@ -2728,7 +3082,7 @@ export default function TransferOrderPopupPage() {
2728
3082
  - [ ] 서버에서 페이지 단위로 받는 `SSelect` 에 `onReachEnd` 와 `hasMore`·`loading`·`serverSearch` 를 함께 줬는가, 늦게 온 응답을 버리는 cleanup 이 있는가 (§3-7-2 — 렌더 최적화는 DS 가 알아서 한다)
2729
3083
  - [ ] `SKeyValueTable` 의 짧은 행에 `tdColSpan` 을 주어 전체 열 수를 채웠는가 (§4-3 — 안 채우면 그 구간의 행 구분선이 끊긴다)
2730
3084
  - [ ] 날짜·시간 필드가 섞인 `SKeyValueTable` 에서 오른쪽 끝을 맞출지 정했는가 (§4-3 `fieldWidth` — 필터는 `"fill"`, 등록·수정 폼은 기본값. 필드마다 `maxWidth` 를 주지 않는다)
2731
- - [ ] 하단 버튼이 양끝 분리(`justify-between`)이고, 되돌리기가 왼쪽 끝 · 실행이 오른쪽 끝인가
3085
+ - [ ] 하단 버튼이 양끝 분리(`justify-between`)이고, 되돌리기가 왼쪽 끝 · 실행이 오른쪽 끝인가 (되돌리는 액션이 없어도 양끝 분리는 그대로 — 오른쪽에 모으지 않고 위계가 낮은 쪽을 왼쪽 끝에 둔다)
2732
3086
  - [ ] 페이지에 `color="primary"` 채움 버튼이 **1개뿐**인가 (`danger` 채움도 1개, `SDropdownButton` 포함)
2733
3087
  - [ ] 버튼 `size` 가 위치 규칙과 맞는가 (행 내부 `xs` / 화면 액션 `sm` / 모달 푸터 `md`)
2734
3088
  - [ ] 파괴 액션의 채움/outline 이 **적용 범위 + 위험도** 기준과 맞는가