sellmate-design-system-react 9.0.0-beta.62 → 9.0.0-beta.64
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 +486 -131
- package/dist/components/SCalendarBoard/CalendarDayCard.d.ts +11 -3
- package/dist/components/SCalendarBoard/CalendarEventButton.d.ts +7 -2
- package/dist/components/SCalendarBoard/CalendarMoreButton.d.ts +2 -1
- package/dist/components/SCalendarBoard/README.md +11 -2
- package/dist/components/SCalendarBoard/SCalendarBoard.d.ts +4 -1
- package/dist/components/SCalendarBoard/calendar-board-layout.d.ts +49 -0
- package/dist/components/SCalendarBoard/types.d.ts +10 -1
- package/dist/components/SPage/README.md +19 -0
- package/dist/components/SPage/SPage.d.ts +11 -2
- package/dist/components/SPage/index.d.ts +1 -1
- package/dist/components/SPage/page.config.d.ts +8 -0
- package/dist/docs/RecipeGallery.d.ts +13 -0
- package/dist/index.cjs +168 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +168 -57
- package/dist/index.js.map +1 -1
- package/dist/llms-full.txt +516 -133
- package/dist/llms.txt +486 -131
- package/dist/recipes/AppShell.d.ts +21 -0
- package/dist/recipes/ExcelFilePopupPage.d.ts +5 -0
- package/dist/recipes/LoginPage.d.ts +6 -0
- package/dist/recipes/ProductCreatePage.d.ts +1 -0
- package/dist/recipes/ProductCreateSectionsPage.d.ts +5 -0
- package/dist/recipes/ProductDetailInlineEditPage.d.ts +9 -0
- package/dist/recipes/ProductDetailPage.d.ts +1 -0
- package/dist/recipes/ProductListChipFilterPage.d.ts +1 -0
- package/dist/recipes/ProductListPage.d.ts +1 -0
- package/dist/recipes/TransferOrderPopupPage.d.ts +2 -0
- package/dist/recipes/api.d.ts +91 -0
- package/dist/recipes/recipe-story.d.ts +33 -0
- package/dist/recipes/router.d.ts +9 -0
- package/dist/styles.css +26 -3
- package/package.json +3 -1
package/dist/llms-full.txt
CHANGED
|
@@ -316,6 +316,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
|
|
|
316
316
|
| 상황 | 값 |
|
|
317
317
|
| --- | --- |
|
|
318
318
|
| **페이지 콘텐츠 패딩** | **`SPage` 가 `--cmp-pageBody-padding-default` 로 이미 넣는다.** 직접 주지 않는다 (덮어쓰면 토큰이 바뀌어도 안 따라간다). `SPage` 밖에서 같은 패딩이 필요하면 `p-sd-16` |
|
|
319
|
+
| **가장자리까지 차는 블록이 맨 위에 서는 페이지** (`SChipFilter` 바 등) | `SPage` 에 `padding="none"` 으로 끄고, **그 아래 블록이 `p-[var(--cmp-pageBody-padding-default)]` 로 같은 토큰의 여백을 잡는다.** `className="p-0"` 이나 `p-sd-16` 으로 적지 않는다 (§4-2 칩 필터) |
|
|
319
320
|
| **섹션 ↔ 섹션**, **블록 ↔ 블록** (헤더·필터·본문 사이) | **`gap-sd-12`** |
|
|
320
321
|
| **툴바 ↔ 그 표** (`STableBar` ↔ `STable`) | **간격 없음 — 붙인다.** 둘은 한 블록이다 (§2-0) |
|
|
321
322
|
| 요소 ↔ 요소 | **기본 `gap-sd-8`** (`--sys-space-stack-gap-element-normal`) · 타이트 `gap-sd-4`(`-tight`) · 여유 `gap-sd-12`(`-relaxed`) / `gap-sd-16`(`-wide`) |
|
|
@@ -415,7 +416,7 @@ Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.
|
|
|
415
416
|
```
|
|
416
417
|
|
|
417
418
|
- **`bordered={false}`** 로 표의 테두리를 끈다. 카드가 이미 그린다.
|
|
418
|
-
- **`radius="useTop"`** 으로 위쪽 모서리를 죽인다. 아래쪽 라운드는 카드가 처리한다.
|
|
419
|
+
- **`radius="useTop"`** 으로 위쪽 모서리를 죽인다. 아래쪽 라운드는 카드가 처리한다. **표 아래에 카드 푸터(`button`·`footerLeft`)가 오면 `radius="full"`** — 아래 모서리도 카드가 아니라 푸터의 상단 구분선이 이어받으므로 네 모서리를 모두 죽인다.
|
|
419
420
|
- `STable` 은 **페이지네이션 바의 테두리와 `-mt-px` 겹침도 함께 꺼진다** — 본문 테두리가 없으면 겹칠 대상이 없어, 그대로 두면 페이지네이션만 테두리를 갖고 1px 어긋난다.
|
|
420
421
|
|
|
421
422
|
#### 본문 바탕 눌러앉히기 (선택)
|
|
@@ -1360,6 +1361,7 @@ tableRef.current.scrollToRow(row); // 복원
|
|
|
1360
1361
|
- **왼쪽 끝**: 취소·닫기·목록 등 되돌리는 액션
|
|
1361
1362
|
- **오른쪽 끝**: 저장·등록·수정·삭제 등 실행 액션
|
|
1362
1363
|
- 체크박스·안내 문구 등 **부가 요소는 오른쪽 그룹 안, 실행 버튼 바로 왼쪽**에 둔다
|
|
1364
|
+
- **되돌리는 액션이 없어도 양끝 분리는 그대로다** — 오른쪽에 모으지 않는다. 그때는 **위계가 낮은 실행 액션이 왼쪽 끝, 가장 강한 실행 액션이 오른쪽 끝**이다. 뒤로가기를 헤더의 `onBack` 이 맡은 상세 페이지(§4-4)의 `삭제`(`danger outline`) · `수정`(`primary` 채움)이 이 경우다.
|
|
1363
1365
|
|
|
1364
1366
|
```tsx
|
|
1365
1367
|
<div className="flex items-center justify-between">
|
|
@@ -1707,17 +1709,18 @@ useEffect(() => {
|
|
|
1707
1709
|
| 연도+월 선택 리스트만 필요하다 (트리거·팝오버는 직접 조합) | `SDatePickerMonthListbox` |
|
|
1708
1710
|
| **시작~종료** 를 값으로 받는다 | `SDateRangePicker` |
|
|
1709
1711
|
| 달력 격자 **자체가 화면 콘텐츠** 다 (날짜를 고르게 한다) | `SCalendar` |
|
|
1710
|
-
| 한 달치 **일정을 읽게** 펼친다 (칸마다 여러
|
|
1712
|
+
| 한 달치 **일정을 읽게** 펼친다 (칸마다 여러 건, 여러 날에 걸친 일정 포함) | `SCalendarBoard` |
|
|
1711
1713
|
|
|
1712
1714
|
- **기간을 `SDatePicker` 두 개로 만들지 않는다.** 시작이 종료보다 뒤인 입력을 막는 검증과 한쪽만 고른 중간 상태 처리가 `SDateRangePicker` 안에 이미 있다. 두 개로 쪼개면 그게 전부 앱 몫이 된다.
|
|
1713
1715
|
- `SDatePicker`·`SDateRangePicker` 는 내부적으로 `SCalendar` 를 팝오버로 띄운다. 값을 받는 자리에 `SCalendar` 를 직접 쓰지 않는다.
|
|
1714
1716
|
- **`SCalendar` 와 `SCalendarBoard` 는 방향이 반대다.** `SCalendar` 는 날짜를 **고르는** 달력(칸에는 점만 찍힌다)이고, `SCalendarBoard` 는 일정을 **읽는** 달력이다 — 칸마다 일정이 줄로 쌓이고, 칸 높이에 담기는 줄 수를 넘으면 "N개 더보기" 로 접히고, **"더보기" 또는 일정이 있는 칸 아무 데나** 누르면 **그 칸을 덮으며 뜨는 판**에 그 날짜의 일정이 전부 펼쳐진다. 폭도 다르다: `SCalendar` 는 제 폭을 갖는 요소, `SCalendarBoard` 는 페이지 폭을 채우는 블록이다.
|
|
1715
1717
|
- **`SCalendarBoard` 는 기본이 읽기 전용 판이다.** 날짜 칸을 눌러 **고르게** 하려면 `selectable` 을 켠다 — 켜야 `onValueChange(date, events)` 로 고른 날짜와 **그 날짜의 일정**이 함께 온다. 다만 **일정이 있는 칸은 `selectable` 과 무관하게 눌린다** — 그때는 목록 판이 열린다. 둘 다 켜져 있으면 고르기와 열기가 함께 일어난다. 일정 버튼과 "N개 더보기" 도 항상 눌린다.
|
|
1718
|
+
- **여러 날에 걸친 일정은 `events[].endDate` 로 준다.** 그날까지(그날 포함) **칸 경계를 넘어 하나의 막대로** 이어져 그려진다. 같은 일정을 날짜 수만큼 쪼개 넣지 않는다 — 쪼개면 칸마다 막대가 끊겨 보이고, 목록 판과 `onValueChange` 에도 건수가 부풀려 나간다. 줄은 **주 단위**로 정해져서, 막대가 지나가는 칸은 그 줄을 비워 두므로 다른 일정과 겹치지 않는다. 주 경계에서는 줄이 바뀌므로 조각이 나뉘고 **잘린 쪽 모서리가 평평해진다** — 앞/뒤로 이어진다는 표시다. **막대가 걸친 날은 모두 그 일정을 가진 날이다** — 어느 칸을 눌러도 목록 판에 함께 나오고 `onValueChange` 에도 실려 온다. `endDate` 가 `date` 보다 앞서면 하루짜리로 본다.
|
|
1716
1719
|
- **칸 배경과 일정 색은 다른 층이다.** 그날의 성격(마감·완료·휴무 등)은 `dayColors` 로 **칸 전체**에 칠하고(`default` `danger` `accent` `complete` `dim`), 일정 하나의 색은 `events[].color` 로 준다. 둘을 섞어 쓰면 서로를 가리지 않고 함께 읽힌다.
|
|
1717
1720
|
- 일정 왼쪽의 색 점은 `SBadge` 다 — `events[].color` 프리셋이 점 색까지 정하고, 따로 주려면 `dotColor` 에 `SBadge` 색(`SBadgeColor`) 중 하나를 넣는다 (§3-1).
|
|
1718
1721
|
- **칸 본문을 통째로 바꾸려면 `dayContent` 다.** 일정으로 표현되지 않는 그날치 요약(재고·매출 같은)을 칸에 얹을 때 쓴다. **슬롯을 준 날짜는 그 날짜의 `events` 를 그리지 않고 목록 판도 열지 않는다** — 한 칸에 둘을 겹쳐 담지 않는다. 일정과 요약을 함께 보여야 하면 요약을 일정 한 건(`events[]`)으로 넣는다.
|
|
1719
1722
|
- **`SCalendarBoard` 는 높이를 부모에게서 받는다.** 행이 그 높이를 균등하게 나눠 갖고, 칸 높이가 다시 "한 칸에 몇 줄이 보일지" 를 정한다. 그래서 **놓는 자리에 높이를 준다** — 페이지 본문의 남은 높이를 채우려면 `SPage` 안에서 스택을 `flex-1 min-h-0` 으로 두고 판에 `h-full` 을 준다. 높이를 주지 않으면 칸이 최소 높이로 주저앉아 한두 줄만 보인다.
|
|
1720
|
-
- **한 칸에 몇 줄을 펼칠지는 컴포넌트가 잰다.** 조절하는 prop 이 없다 — 칸 높이에서 나오는 값이라 따로 정할 것이 아니다. 줄이 넘치면 마지막 한 줄을 "N개 더보기" 에 내주므로 **접히든 아니든 칸 높이가 같다.** **소비 앱이 `events` 를 미리 잘라 넘기지 않는다.** 잘라 넘기면 "N개 더보기" 의 수가 실제 건수와 어긋나고, 눌러도 나머지가 나오지 않는다.
|
|
1723
|
+
- **한 칸에 몇 줄을 펼칠지는 컴포넌트가 잰다.** 조절하는 prop 이 없다 — 칸 높이에서 나오는 값이라 따로 정할 것이 아니다. 줄이 넘치면 마지막 한 줄을 "N개 더보기" 에 내주므로 **접히든 아니든 칸 높이가 같다.** 기간 막대가 접히면 **걸친 날마다 한 건으로** 세므로 날짜별 "N개" 가 서로 다를 수 있다. **소비 앱이 `events` 를 미리 잘라 넘기지 않는다.** 잘라 넘기면 "N개 더보기" 의 수가 실제 건수와 어긋나고, 눌러도 나머지가 나오지 않는다.
|
|
1721
1724
|
- **`events[].label` 은 필수다** — 일정이 무엇인지 알리는 부분이라 비워 두지 않는다. 인라인 HTML 을 받으므로 부분 강조가 필요할 때만 태그를 쓰고, 나머지는 `description`·`value` 로 나눠 담는다. 칸이 좁아지면 `label`+`description` 이 한 덩어리로 말줄임되고 `value` 만 끝에 온전히 남는다 — **금액·수량은 반드시 `value` 에 담는다.** `description` 에 넣으면 잘린다.
|
|
1722
1725
|
- `SDatePickerYearListbox`·`SDatePickerMonthListbox` 는 `SDatePicker` 의 mode listbox 조각만 떼어낸 컴포넌트다. 일반 폼 입력에는 `SDatePicker mode="year" | "month"` 를 우선 쓰고, 다른 트리거·팝오버 안에 리스트만 끼워 넣을 때만 직접 쓴다.
|
|
1723
1726
|
|
|
@@ -1960,6 +1963,8 @@ const [selectedId, setSelectedId] = useState<string>();
|
|
|
1960
1963
|
>
|
|
1961
1964
|
> **아래 레시피는 §2-0 조합 문법으로 유도된 결과다.** 여기 없는 화면(대시보드·설정·마법사 등)을 만들 때는 임의로 짜지 말고 §2-0 의 층 구조·포함 규칙·블록 순서로 직접 유도한다.
|
|
1962
1965
|
>
|
|
1966
|
+
> **레시피 코드의 `./AppShell` 은 §4-1 의 셸, `./api`·`./router` 는 앱의 데이터 계층·라우터 자리다.** 각 레시피는 Storybook `Recipes/…` 에서 실제로 띄워 볼 수 있고, 아래에 적힌 분기(칩 필터·섹션 나눔 등)의 변형 코드도 거기 있다.
|
|
1967
|
+
>
|
|
1963
1968
|
> **핵심 원칙 — 표 형태의 정보는 `SKeyValueTable` 로 만든다.** 필터·등록/수정 폼·상세 정보가 모두 여기 해당한다.
|
|
1964
1969
|
> `SField` 컨트롤을 `div` 로 직접 나열해 폼을 만들지 않는다.
|
|
1965
1970
|
> (필터만 예외가 하나 있다 — 조건 후보가 많아 골라 붙이게 해야 하면 `SChipFilter` 다. §3-7-11)
|
|
@@ -1967,7 +1972,11 @@ const [selectedId, setSelectedId] = useState<string>();
|
|
|
1967
1972
|
### 4-1. 앱 셸 (모든 페이지 공통)
|
|
1968
1973
|
|
|
1969
1974
|
```tsx
|
|
1970
|
-
import
|
|
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';
|
|
1971
1980
|
|
|
1972
1981
|
const MENU: SGnbMenuItem[] = [
|
|
1973
1982
|
{ label: '주문', value: 'orders', icon: 'bill' },
|
|
@@ -1979,12 +1988,15 @@ export default function AppShell({
|
|
|
1979
1988
|
header,
|
|
1980
1989
|
scrollEndSpacing,
|
|
1981
1990
|
contentHeight,
|
|
1991
|
+
contentWidth,
|
|
1992
|
+
padding,
|
|
1982
1993
|
}: {
|
|
1983
|
-
children:
|
|
1994
|
+
children: ReactNode;
|
|
1984
1995
|
header?: SPageHeaderProps;
|
|
1985
1996
|
scrollEndSpacing?: boolean;
|
|
1986
1997
|
contentHeight?: SPageContentHeight;
|
|
1987
1998
|
contentWidth?: SPageContentWidth;
|
|
1999
|
+
padding?: SPagePadding;
|
|
1988
2000
|
}) {
|
|
1989
2001
|
return (
|
|
1990
2002
|
<SLayout type="box" header="fix">
|
|
@@ -1993,7 +2005,8 @@ export default function AppShell({
|
|
|
1993
2005
|
{/* 페이지 제목은 SPage 앞에 형제로 둔다 — SLayout 이 헤더부터 뒤를 한 열로 묶는다.
|
|
1994
2006
|
header 는 페이지마다 달라 AppShell 이 그대로 받아 넘긴다 — 여기서 만들지 않는다 */}
|
|
1995
2007
|
{header && <SPageHeader {...header} />}
|
|
1996
|
-
{/* 콘텐츠 패딩은 SPage 가 토큰으로 넣는다 — p-sd-* 로 덮어쓰지
|
|
2008
|
+
{/* 콘텐츠 패딩은 SPage 가 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다.
|
|
2009
|
+
가장자리까지 차는 블록이 맨 위에 서는 페이지만 padding="none" 으로 끈다 (§4-2 칩 필터) */}
|
|
1997
2010
|
{/* 높이 모드는 페이지가 정한다 — 대부분 contentHeight="fill" 이다 (§2-2) */}
|
|
1998
2011
|
{/* 스크롤 끝 여백도 SPage 가 넣는다. 페이지가 실제로 스크롤되는 화면에서만 켠다 */}
|
|
1999
2012
|
{/* 가로 폭 모드도 페이지가 정한다 — 넓은 창에서 본문을 늘릴지 최소 폭으로 세울지 */}
|
|
@@ -2002,6 +2015,7 @@ export default function AppShell({
|
|
|
2002
2015
|
scrollEndSpacing={scrollEndSpacing}
|
|
2003
2016
|
contentHeight={contentHeight}
|
|
2004
2017
|
contentWidth={contentWidth}
|
|
2018
|
+
padding={padding}
|
|
2005
2019
|
>
|
|
2006
2020
|
{children}
|
|
2007
2021
|
</SPage>
|
|
@@ -2207,7 +2221,7 @@ import { SModalOutlet } from 'sellmate-design-system-react';
|
|
|
2207
2221
|
|
|
2208
2222
|
**뒤의 둘은 한 블록이라 붙여 쓴다** — 툴바는 자기 아래 표의 건수와 액션을 말하므로 사이를 벌리지 않는다. 묶는 방법과 맞닿는 변 처리는 §2-0 에 있다.
|
|
2209
2223
|
|
|
2210
|
-
필터 자리는 `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/목록 페이지` 의 "칩 필터" 에 있다.
|
|
2211
2225
|
|
|
2212
2226
|
액션 버튼의 위치가 핵심이다:
|
|
2213
2227
|
|
|
@@ -2219,44 +2233,120 @@ import { SModalOutlet } from 'sellmate-design-system-react';
|
|
|
2219
2233
|
- `fill` 에서는 페이지가 스크롤하지 않으므로 **`scrollEndSpacing` 은 무시된다** — 따로 끄지 않는다 (§2-2).
|
|
2220
2234
|
- **정렬 가능한 컬럼은 `sortable` 로 준다.** 정렬 상태(`sort`)는 이 페이지가 들고 `onSortChange` 로 받는다 — 조회 조건이라 URL 에 실려야 한다 (§3-4).
|
|
2221
2235
|
|
|
2236
|
+
<!-- recipe:src/recipes/ProductListPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
|
|
2222
2237
|
```tsx
|
|
2238
|
+
import { useState } from 'react';
|
|
2223
2239
|
import {
|
|
2224
|
-
SButton,
|
|
2225
|
-
|
|
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,
|
|
2226
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';
|
|
2227
2254
|
|
|
2228
2255
|
/** 필터도 표다 — SKeyValueTable 로 만든다 (서비스 전용 전역 필터가 따로 있는 경우 제외) */
|
|
2229
2256
|
const filterFields: SKeyValueField[][] = [
|
|
2230
2257
|
[
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
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
|
+
},
|
|
2235
2271
|
],
|
|
2236
2272
|
[
|
|
2237
|
-
{
|
|
2273
|
+
{
|
|
2274
|
+
name: 'period',
|
|
2275
|
+
label: '등록일',
|
|
2276
|
+
type: 'date-range-picker',
|
|
2277
|
+
tdColSpan: 3,
|
|
2278
|
+
options: { clearable: true },
|
|
2279
|
+
},
|
|
2238
2280
|
],
|
|
2239
2281
|
];
|
|
2240
2282
|
|
|
2283
|
+
// 정렬·폭은 값의 성격에서 나온다 (§3-4). 모든 컬럼에 폭을 px 로 명시한다
|
|
2241
2284
|
const columns: STableColumn[] = [
|
|
2242
|
-
//
|
|
2243
|
-
{ name: 'id', label: 'ID', field: 'id', width:
|
|
2244
|
-
|
|
2245
|
-
{ name: '
|
|
2246
|
-
|
|
2247
|
-
{
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
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',
|
|
2251
2318
|
render: (row: SRow) =>
|
|
2252
|
-
row.status === 'selling'
|
|
2253
|
-
|
|
2254
|
-
|
|
2319
|
+
row.status === 'selling' ? (
|
|
2320
|
+
<STag size="sm" color="green" label="판매중" />
|
|
2321
|
+
) : (
|
|
2322
|
+
<STag size="sm" color="grey" label="판매중지" />
|
|
2323
|
+
),
|
|
2324
|
+
},
|
|
2255
2325
|
];
|
|
2256
2326
|
|
|
2257
2327
|
export default function ProductListPage() {
|
|
2328
|
+
const navigate = useNavigate();
|
|
2329
|
+
// 편집 중인 필터 값과, 검색을 눌러 실제로 걸린 조회 조건은 따로 든다.
|
|
2330
|
+
// 조회 조건(필터·정렬·페이지)은 실제 앱에서는 URL 쿼리에 싣는다 — 새로고침·공유에 살아남아야 한다
|
|
2258
2331
|
const [filters, setFilters] = useState<Record<string, unknown>>({});
|
|
2332
|
+
const [query, setQuery] = useState<ProductQuery>({ page: 1, rowsPerPage: 20 });
|
|
2259
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
|
+
};
|
|
2260
2350
|
|
|
2261
2351
|
return (
|
|
2262
2352
|
<AppShell
|
|
@@ -2264,7 +2354,15 @@ export default function ProductListPage() {
|
|
|
2264
2354
|
variant: 'bar',
|
|
2265
2355
|
title: '상품 목록',
|
|
2266
2356
|
// 이 페이지의 주요 액션이 아니라 부가 액션 — slot 은 sm 버튼으로만 채운다
|
|
2267
|
-
slot:
|
|
2357
|
+
slot: (
|
|
2358
|
+
<SButton
|
|
2359
|
+
size="sm"
|
|
2360
|
+
color="neutral"
|
|
2361
|
+
outline
|
|
2362
|
+
label="이용 가이드"
|
|
2363
|
+
onClick={() => navigate('/guide/products')}
|
|
2364
|
+
/>
|
|
2365
|
+
),
|
|
2268
2366
|
}}
|
|
2269
2367
|
contentHeight="fill" // 표가 남은 높이를 채우고 페이지네이션이 하단에 고정된다
|
|
2270
2368
|
>
|
|
@@ -2278,10 +2376,11 @@ export default function ProductListPage() {
|
|
|
2278
2376
|
fieldWidth="fill"
|
|
2279
2377
|
search
|
|
2280
2378
|
onChange={({ values }) => setFilters(values)}
|
|
2281
|
-
onSearch={
|
|
2379
|
+
onSearch={search}
|
|
2282
2380
|
/>
|
|
2283
2381
|
|
|
2284
|
-
{/* 툴바+표 = 한 블록. 사이를 벌리지 않는다 (§2-0)
|
|
2382
|
+
{/* 툴바+표 = 한 블록. 사이를 벌리지 않는다 (§2-0).
|
|
2383
|
+
이 div 도 높이 사슬의 한 칸이라 min-h-0 flex-1 이 필요하다 */}
|
|
2285
2384
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
2286
2385
|
{/* 툴바 — 좌: 건수 + (구분선 자동) + 선택 액션 / 우: 주요 액션
|
|
2287
2386
|
맞닿는 가로선은 표가 그리므로 아래 테두리는 지운다 */}
|
|
@@ -2291,25 +2390,37 @@ export default function ProductListPage() {
|
|
|
2291
2390
|
selected={selected.length}
|
|
2292
2391
|
actions={
|
|
2293
2392
|
/* 선택 항목 단위 파괴 액션 → danger outline (§3-5-3) */
|
|
2294
|
-
<SButton
|
|
2295
|
-
|
|
2393
|
+
<SButton
|
|
2394
|
+
size="sm"
|
|
2395
|
+
color="danger"
|
|
2396
|
+
outline
|
|
2397
|
+
label="선택 삭제"
|
|
2398
|
+
disabled={!selected.length}
|
|
2399
|
+
onClick={removeSelected}
|
|
2400
|
+
/>
|
|
2296
2401
|
}
|
|
2297
2402
|
rightActions={
|
|
2298
2403
|
/* 이 페이지의 유일한 primary 채움 (§3-5-1) */
|
|
2299
|
-
<SButton size="sm" label="상품 등록" onClick={
|
|
2404
|
+
<SButton size="sm" label="상품 등록" onClick={() => navigate('/products/new')} />
|
|
2300
2405
|
}
|
|
2301
2406
|
/>
|
|
2302
2407
|
|
|
2303
|
-
{/* 남은 높이를 채우고 본문만 스크롤한다 — 페이지네이션 바는 표 안에서 하단
|
|
2408
|
+
{/* 남은 높이를 채우고 본문만 스크롤한다 — 페이지네이션 바는 표 안에서 하단 고정.
|
|
2409
|
+
dense 는 시작 밀도이자 밀도 토글의 스위치 — onDenseChange 값을 되돌려 넣지 않는다 (§4-2) */}
|
|
2304
2410
|
<STable
|
|
2305
2411
|
className="min-h-0 flex-1"
|
|
2306
2412
|
columns={columns}
|
|
2307
2413
|
rows={rows}
|
|
2308
2414
|
rowKey="id"
|
|
2415
|
+
dense
|
|
2416
|
+
resizable
|
|
2309
2417
|
selectable
|
|
2310
2418
|
selected={selected}
|
|
2311
2419
|
onSelectedChange={setSelected}
|
|
2312
|
-
|
|
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 }))}
|
|
2313
2424
|
loading={loading}
|
|
2314
2425
|
/>
|
|
2315
2426
|
</div>
|
|
@@ -2318,6 +2429,7 @@ export default function ProductListPage() {
|
|
|
2318
2429
|
);
|
|
2319
2430
|
}
|
|
2320
2431
|
```
|
|
2432
|
+
<!-- /recipe -->
|
|
2321
2433
|
|
|
2322
2434
|
#### 한 화면에 더 많은 행을 — `dense` 와 밀도 토글
|
|
2323
2435
|
|
|
@@ -2327,7 +2439,7 @@ export default function ProductListPage() {
|
|
|
2327
2439
|
|
|
2328
2440
|
```tsx
|
|
2329
2441
|
// 좁게 시작하고, 사용자가 바꾸는 밀도는 표가 알아서 들고 간다
|
|
2330
|
-
<STable dense showRowsPerPageSelect pagination={{
|
|
2442
|
+
<STable dense showRowsPerPageSelect pagination={{ page, rowsPerPage, lastPage }} />;
|
|
2331
2443
|
|
|
2332
2444
|
// 사용자가 고른 밀도를 다음 방문에도 남기려는 화면만 받아서 저장한다.
|
|
2333
2445
|
// 저장한 값은 시작 밀도로만 돌려준다 — 되돌려 넣지 않는다
|
|
@@ -2337,7 +2449,7 @@ const [initialDense] = useState(() => loadPref('list.dense', true));
|
|
|
2337
2449
|
dense={initialDense}
|
|
2338
2450
|
onDenseChange={next => savePref('list.dense', next)}
|
|
2339
2451
|
showRowsPerPageSelect
|
|
2340
|
-
pagination={{
|
|
2452
|
+
pagination={{ page, rowsPerPage, lastPage }}
|
|
2341
2453
|
/>;
|
|
2342
2454
|
```
|
|
2343
2455
|
|
|
@@ -2402,61 +2514,113 @@ const [initialDense] = useState(() => loadPref('list.dense', true));
|
|
|
2402
2514
|
|
|
2403
2515
|
**병합으로 찬 자리는 배열 순서가 아니라 실제 열 위치로 판정한다.** 그러니 아래 행의 필드를 병합된 열에 맞추려고 자리 채우기용 빈 필드를 끼우지 않는다 — 앞 필드에 `tdColSpan` 이나 `hideTh` 가 있어도 남은 필드가 알아서 빈 열부터 놓인다.
|
|
2404
2516
|
|
|
2517
|
+
<!-- recipe:src/recipes/ProductCreatePage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
|
|
2405
2518
|
```tsx
|
|
2519
|
+
import { useState } from 'react';
|
|
2406
2520
|
import {
|
|
2407
|
-
|
|
2408
|
-
|
|
2521
|
+
SButton,
|
|
2522
|
+
SCheckbox,
|
|
2523
|
+
SForm,
|
|
2524
|
+
SKeyValueTable,
|
|
2525
|
+
type Rule,
|
|
2526
|
+
type SKeyValueField,
|
|
2409
2527
|
} from 'sellmate-design-system-react';
|
|
2528
|
+
import AppShell from './AppShell';
|
|
2529
|
+
import { CATEGORY_OPTIONS, saveProduct } from './api';
|
|
2530
|
+
import { useNavigate } from './router';
|
|
2410
2531
|
|
|
2411
|
-
const required =
|
|
2412
|
-
|
|
2532
|
+
const required =
|
|
2533
|
+
(msg: string): Rule =>
|
|
2534
|
+
v =>
|
|
2535
|
+
v != null && String(v).trim() !== '' ? true : msg;
|
|
2413
2536
|
|
|
2537
|
+
/** 필드를 div 로 나열하지 않는다 — SKeyValueTable 의 행으로 구성한다 */
|
|
2414
2538
|
const fields: SKeyValueField[][] = [
|
|
2415
2539
|
[
|
|
2416
|
-
{
|
|
2417
|
-
|
|
2540
|
+
{
|
|
2541
|
+
name: 'name',
|
|
2542
|
+
label: '상품명',
|
|
2543
|
+
required: true,
|
|
2544
|
+
type: 'input',
|
|
2545
|
+
options: { placeholder: '상품명 입력', rules: [required('상품명을 입력해 주세요.')] },
|
|
2546
|
+
},
|
|
2418
2547
|
{ name: 'code', label: '상품코드', type: 'input', options: { placeholder: '자동 생성' } },
|
|
2419
2548
|
],
|
|
2420
2549
|
[
|
|
2421
|
-
{
|
|
2422
|
-
|
|
2423
|
-
|
|
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
|
+
},
|
|
2424
2561
|
{ name: 'price', label: '판매가', type: 'number-input' },
|
|
2425
2562
|
],
|
|
2426
2563
|
[
|
|
2427
|
-
|
|
2428
|
-
|
|
2564
|
+
// 가장 긴 행(필드 2개 = 4칸)에 맞춰 마지막 필드가 남는 칸을 채운다
|
|
2565
|
+
{
|
|
2566
|
+
name: 'memo',
|
|
2567
|
+
label: '메모',
|
|
2568
|
+
type: 'textarea',
|
|
2569
|
+
tdColSpan: 3,
|
|
2570
|
+
helpText: ['내부 관리용 메모입니다.'],
|
|
2571
|
+
},
|
|
2429
2572
|
],
|
|
2430
2573
|
];
|
|
2431
2574
|
|
|
2432
2575
|
export default function ProductCreatePage() {
|
|
2433
|
-
const
|
|
2576
|
+
const navigate = useNavigate();
|
|
2434
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
|
+
};
|
|
2435
2586
|
|
|
2436
2587
|
return (
|
|
2437
|
-
<AppShell
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
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
|
+
/>
|
|
2445
2598
|
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
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="저장" />
|
|
2453
2616
|
</div>
|
|
2454
|
-
</
|
|
2455
|
-
</
|
|
2617
|
+
</div>
|
|
2618
|
+
</SForm>
|
|
2456
2619
|
</AppShell>
|
|
2457
2620
|
);
|
|
2458
2621
|
}
|
|
2459
2622
|
```
|
|
2623
|
+
<!-- /recipe -->
|
|
2460
2624
|
|
|
2461
2625
|
### 4-4. 상세(조회) 페이지
|
|
2462
2626
|
|
|
@@ -2464,7 +2628,7 @@ export default function ProductCreatePage() {
|
|
|
2464
2628
|
|
|
2465
2629
|
- 조회 값은 `type: 'text'` 행으로 표시한다. **상태·분류 태그도 별도 영역이 아니라 표의 한 행**으로 넣는다 (`render` 에 `STag`).
|
|
2466
2630
|
- 행이 많아지면 **유형별로 섹션을 나누고, 각 섹션을 `SSectionHeaderCard` 로 감싼다.**
|
|
2467
|
-
섹션 제목은 `title` prop 으로, 바디 여백은 `padding` prop 으로 준다.
|
|
2631
|
+
섹션 제목은 `title` prop 으로, 바디 여백은 `padding` prop 으로 준다. **카드 안이 `SKeyValueTable` 하나뿐이면 `padding="none"` 으로 가장자리까지 채우고 표는 `bordered={false} radius="useTop"`** 으로 자기 테두리를 끈다 (§2-2 "카드 가장자리까지 채우는 표").
|
|
2468
2632
|
- **한 화면에 행이 많아 훑어보기가 목적이면 `SKeyValueTable` 에 `dense` 를 켠다** — 셀 상하 여백과
|
|
2469
2633
|
최소 높이가 함께 내려가 행이 좁아진다. 다만 **읽기 전용(`text`) 행에서만 실제로 좁아진다**:
|
|
2470
2634
|
폼 필드가 든 행은 필드 자체의 높이가 행 높이를 정하므로 거의 변하지 않는다. 그래서 필터(§4-2)나
|
|
@@ -2473,23 +2637,19 @@ export default function ProductCreatePage() {
|
|
|
2473
2637
|
- **상세 페이지도 `contentHeight="fill"` 이 기본이다**(§2-2). 섹션이 많아 페이지가 실제로 스크롤되는 화면에서만 `auto` + `scrollEndSpacing` 을 켠다.
|
|
2474
2638
|
- **섹션마다 독립 인라인 폼이 있는 형태**도 상세 페이지의 변형이다. 섹션 안에서 바로 수정·저장하게 하는 화면인데, 이때 버튼 강조는 **섹션 단위가 아니라 페이지 단위로 판단한다** — §3-5-1 의 "`secondary` 연속 배치 금지"는 섹션이 다르면 적용되지 않는다. 그 섹션 안에서 닫히는 저장·취소는 `SSectionHeaderCard` 의 `button`·`footerLeft` 로 넘긴다 (§3-7-8). 아래 예처럼 **페이지 전체를 확정하는 버튼은 카드 밖 하단 줄**에 둔다 — 둘을 섞지 않는다.
|
|
2475
2639
|
|
|
2640
|
+
<!-- recipe:src/recipes/ProductDetailPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
|
|
2476
2641
|
```tsx
|
|
2477
2642
|
import {
|
|
2478
|
-
|
|
2643
|
+
SButton,
|
|
2644
|
+
SKeyValueTable,
|
|
2645
|
+
SModal,
|
|
2646
|
+
SSectionHeaderCard,
|
|
2647
|
+
STag,
|
|
2648
|
+
type SKeyValueField,
|
|
2479
2649
|
} from 'sellmate-design-system-react';
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
{ name: 'code', label: '상품코드', type: 'text' },
|
|
2484
|
-
{ name: 'createdAt', label: '등록일', type: 'text' },
|
|
2485
|
-
],
|
|
2486
|
-
[
|
|
2487
|
-
{ name: 'category', label: '카테고리', type: 'text' },
|
|
2488
|
-
// 상태 태그도 표의 한 행으로 표현한다
|
|
2489
|
-
{ name: 'status', label: '상태',
|
|
2490
|
-
render: <STag size="sm" color="green" label="판매중" /> },
|
|
2491
|
-
],
|
|
2492
|
-
];
|
|
2650
|
+
import AppShell from './AppShell';
|
|
2651
|
+
import { removeProducts, useProduct } from './api';
|
|
2652
|
+
import { useNavigate, useParams } from './router';
|
|
2493
2653
|
|
|
2494
2654
|
const priceFields: SKeyValueField[][] = [
|
|
2495
2655
|
[
|
|
@@ -2499,41 +2659,96 @@ const priceFields: SKeyValueField[][] = [
|
|
|
2499
2659
|
];
|
|
2500
2660
|
|
|
2501
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
|
+
|
|
2502
2700
|
return (
|
|
2503
2701
|
// 목록에서 들어온 상세 페이지 — onBack 으로 뒤로가기를 준다
|
|
2504
|
-
<AppShell
|
|
2702
|
+
<AppShell
|
|
2703
|
+
header={{ variant: 'bar', title: product.name, onBack: () => navigate('/products') }}
|
|
2704
|
+
contentHeight="fill" // 섹션이 많아 실제로 스크롤될 때만 auto + scrollEndSpacing
|
|
2705
|
+
>
|
|
2505
2706
|
<div className="flex flex-col gap-sd-12">
|
|
2506
|
-
|
|
2507
|
-
|
|
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" />
|
|
2508
2713
|
</SSectionHeaderCard>
|
|
2509
2714
|
|
|
2510
|
-
{/* 헤더 우측에 액션이 필요하면 slot 을 쓴다 */}
|
|
2715
|
+
{/* 헤더 우측에 액션이 필요하면 slot 을 쓴다 — 이동 액션이라 outline (§3-5-1) */}
|
|
2511
2716
|
<SSectionHeaderCard
|
|
2512
2717
|
title="가격 정보"
|
|
2513
2718
|
marker
|
|
2514
2719
|
helpText={['부가세 포함 금액입니다.']}
|
|
2515
|
-
|
|
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
|
+
}
|
|
2516
2730
|
>
|
|
2517
|
-
<SKeyValueTable fields={priceFields} values={product} />
|
|
2731
|
+
<SKeyValueTable fields={priceFields} values={product} bordered={false} radius="useTop" />
|
|
2518
2732
|
</SSectionHeaderCard>
|
|
2519
2733
|
|
|
2520
|
-
{/* 액션은 하단 —
|
|
2734
|
+
{/* 액션은 하단 — 그룹을 모으지 않고 양끝으로 벌린다 (§3-5).
|
|
2735
|
+
목록으로 돌아가기는 헤더의 onBack 이 이미 하므로 되돌리는 버튼을 따로 두지 않는다.
|
|
2736
|
+
되돌리는 액션이 없어도 양끝 분리는 그대로다 — 위계가 낮은 삭제(danger outline)가 왼쪽 끝,
|
|
2737
|
+
가장 강한 실행 액션인 수정이 오른쪽 끝 */}
|
|
2521
2738
|
<div className="flex items-center justify-between">
|
|
2522
|
-
<SButton color="
|
|
2523
|
-
<
|
|
2524
|
-
<SButton color="danger" outline label="삭제" onClick={confirmDelete} />
|
|
2525
|
-
<SButton label="수정" onClick={goEdit} />
|
|
2526
|
-
</div>
|
|
2739
|
+
<SButton color="danger" outline label="삭제" onClick={confirmDelete} />
|
|
2740
|
+
<SButton label="수정" onClick={() => navigate(`/products/${id}/edit`)} />
|
|
2527
2741
|
</div>
|
|
2528
2742
|
</div>
|
|
2529
2743
|
</AppShell>
|
|
2530
2744
|
);
|
|
2531
2745
|
}
|
|
2532
2746
|
```
|
|
2747
|
+
<!-- /recipe -->
|
|
2533
2748
|
|
|
2534
2749
|
### 4-5. 섹션 카드 — SSectionHeaderCard
|
|
2535
2750
|
|
|
2536
|
-
폼 페이지에서도 입력 항목이 많으면 유형별로 `SSectionHeaderCard` 로 나눈다. 주요 옵션:
|
|
2751
|
+
폼 페이지에서도 입력 항목이 많으면 유형별로 `SSectionHeaderCard` 로 나눈다. **카드 안이 `SKeyValueTable` 하나뿐이면 `padding="none"` 으로 가장자리까지 채우고, 표는 `bordered={false} radius="useTop"` 으로 자기 테두리를 끈다** (§2-2 "카드 가장자리까지 채우는 표"). 코드는 Storybook `Recipes/폼 페이지` 의 "섹션 나눔" 에 있다. 주요 옵션:
|
|
2537
2752
|
|
|
2538
2753
|
| Prop (Header) | 용도 |
|
|
2539
2754
|
| --- | --- |
|
|
@@ -2576,24 +2791,48 @@ export default function ProductDetailPage() {
|
|
|
2576
2791
|
|
|
2577
2792
|
로그인 전이라 앱 셸이 아직 없다. `SLayout`·`SGnb`·`SPage` 가 없고, 회색 바탕 위에 카드 하나만 선다. (앱 셸 없이 서는 화면은 이것과 팝업 라우트(§4-7) 둘뿐이다.)
|
|
2578
2793
|
|
|
2794
|
+
<!-- recipe:src/recipes/LoginPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
|
|
2579
2795
|
```tsx
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
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
|
+
}
|
|
2592
2830
|
```
|
|
2831
|
+
<!-- /recipe -->
|
|
2593
2832
|
|
|
2594
2833
|
**바깥 한 겹은 앱이 만든다.** 카드는 화면을 잡지 않는다 — 덕분에 로그인 화면 밖(세션 만료 재인증 등)에서도 같은 카드를 그대로 쓴다. 그 한 겹은 위 세 가지만 하면 되고, 그 이상은 하지 않는다.
|
|
2595
2834
|
|
|
2596
|
-
- 바탕은 `bg-
|
|
2835
|
+
- 바탕은 `bg-bg-neutralLight`, 여백은 `p-sd-24`. **여백은 카드가 최소 폭(368px)을 지킬 수 있는 값으로 둔다** — 좌우 합이 창 폭에서 368px 을 남기지 못하면 카드가 넘친다
|
|
2597
2836
|
- `items-stretch` — 카드가 남은 높이를 채운다. `items-center` 로 두면 카드가 내용 높이로 줄어든다
|
|
2598
2837
|
- `justify-center` — 카드를 가로 가운데에
|
|
2599
2838
|
|
|
@@ -2620,9 +2859,23 @@ export default function ProductDetailPage() {
|
|
|
2620
2859
|
|
|
2621
2860
|
앱 셸 없이 서는 또 하나의 화면이다. 별도 브라우저 창으로 열리는 전용 라우트이므로 `SLayout`·`SGnb`·`SPage` 가 없고, 창을 통째로 `SPopup` 하나가 채운다. 무엇을 팝업으로 열지는 §3-3-1·§3-3-3 에서 고르고, 여기서는 그 라우트의 골격만 다룬다.
|
|
2622
2861
|
|
|
2862
|
+
<!-- recipe:src/recipes/TransferOrderPopupPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
|
|
2623
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
|
+
|
|
2624
2877
|
// 1) 목록에서 별도 창을 연다 — 창 크기 = 콘텐츠 크기
|
|
2625
|
-
function
|
|
2878
|
+
export function openTransferOrderPopup(orderId: string) {
|
|
2626
2879
|
window.open(
|
|
2627
2880
|
`${window.location.origin}/popup/transfer-orders/${orderId}`,
|
|
2628
2881
|
`transfer-order-${orderId}`,
|
|
@@ -2630,21 +2883,55 @@ function openDetailPopup(orderId: string) {
|
|
|
2630
2883
|
);
|
|
2631
2884
|
}
|
|
2632
2885
|
|
|
2633
|
-
|
|
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)이 없다. 조회만 하므로 푸터도 없다
|
|
2634
2909
|
export default function TransferOrderPopupPage() {
|
|
2910
|
+
const { orderId } = useParams();
|
|
2911
|
+
const order = useTransferOrder(orderId);
|
|
2912
|
+
const [tab, setTab] = useState('items');
|
|
2913
|
+
|
|
2635
2914
|
return (
|
|
2636
2915
|
// h-screen 으로 창 높이를 잡는다 — 이게 없으면 본문이 창 밖으로 자란다
|
|
2637
2916
|
<SPopup className="h-screen" popupTitle="이동 오더 상세">
|
|
2638
2917
|
{/* 본문 패딩은 SPopup 이 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다 */}
|
|
2639
2918
|
<div className="flex flex-col gap-sd-12">
|
|
2640
|
-
|
|
2641
|
-
<
|
|
2642
|
-
|
|
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
|
+
)}
|
|
2643
2929
|
</div>
|
|
2644
2930
|
</SPopup>
|
|
2645
2931
|
);
|
|
2646
2932
|
}
|
|
2647
2933
|
```
|
|
2934
|
+
<!-- /recipe -->
|
|
2648
2935
|
|
|
2649
2936
|
**높이는 라우트가 준다 — `SPopup` 에 `h-screen` 을 준다.** `SPopup` 자체는 부모 높이를 채우도록만 되어 있어서, 부모(라우트 루트·`body`)가 높이를 정해 주지 않으면 100% 가 풀려 본문이 창 밖으로 자란다. 그러면 헤더·푸터가 위아래로 밀려 창 안에 보이지 않는다. `h-screen` 이면 `html`·`body`·마운트 루트에 전역 CSS 를 걸지 않고도 창 높이가 바로 들어온다. (`body` 여백은 0 이어야 한다 — Tailwind preflight 가 이미 0 으로 만든다.)
|
|
2650
2937
|
|
|
@@ -2652,28 +2939,96 @@ export default function TransferOrderPopupPage() {
|
|
|
2652
2939
|
|
|
2653
2940
|
**표를 담으면 본문이 아니라 표가 스크롤한다.** 본문이 통째로 스크롤되면 표 헤더와 페이지네이션 바가 위아래로 밀려 사라진다. `SPopup` 의 본문은 이미 남은 높이를 잡고 있으므로, 본문 루트에 `h-full min-h-0` 으로 세로 축을 잇고 표에 `min-h-0 flex-1` 을 준다. **툴바+표를 묶은 `div` 도 사슬의 한 칸이라 거기에도 `min-h-0 flex-1` 이 필요하다** (§4-2 목록 페이지와 같은 사슬이다 — 한 칸만 끊겨도 표가 높이를 못 잡는데 실패가 조용하다).
|
|
2654
2941
|
|
|
2942
|
+
<!-- recipe:src/recipes/ExcelFilePopupPage.tsx — 아래 코드 블록은 이 파일에서 생성된다(scripts/gen-recipes.mjs). 여기를 고치지 말고 파일을 고친다 -->
|
|
2655
2943
|
```tsx
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
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';
|
|
2666
2954
|
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
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
|
+
}
|
|
2676
3030
|
```
|
|
3031
|
+
<!-- /recipe -->
|
|
2677
3032
|
|
|
2678
3033
|
조회만 하고 표가 없는 팝업은 이 사슬이 필요 없다 — 본문이 통째로 스크롤되어도 헤더는 고정이고 밀려날 푸터가 없다.
|
|
2679
3034
|
|
|
@@ -2727,7 +3082,7 @@ export default function TransferOrderPopupPage() {
|
|
|
2727
3082
|
- [ ] 서버에서 페이지 단위로 받는 `SSelect` 에 `onReachEnd` 와 `hasMore`·`loading`·`serverSearch` 를 함께 줬는가, 늦게 온 응답을 버리는 cleanup 이 있는가 (§3-7-2 — 렌더 최적화는 DS 가 알아서 한다)
|
|
2728
3083
|
- [ ] `SKeyValueTable` 의 짧은 행에 `tdColSpan` 을 주어 전체 열 수를 채웠는가 (§4-3 — 안 채우면 그 구간의 행 구분선이 끊긴다)
|
|
2729
3084
|
- [ ] 날짜·시간 필드가 섞인 `SKeyValueTable` 에서 오른쪽 끝을 맞출지 정했는가 (§4-3 `fieldWidth` — 필터는 `"fill"`, 등록·수정 폼은 기본값. 필드마다 `maxWidth` 를 주지 않는다)
|
|
2730
|
-
- [ ] 하단 버튼이 양끝 분리(`justify-between`)이고, 되돌리기가 왼쪽 끝 · 실행이 오른쪽 끝인가
|
|
3085
|
+
- [ ] 하단 버튼이 양끝 분리(`justify-between`)이고, 되돌리기가 왼쪽 끝 · 실행이 오른쪽 끝인가 (되돌리는 액션이 없어도 양끝 분리는 그대로 — 오른쪽에 모으지 않고 위계가 낮은 쪽을 왼쪽 끝에 둔다)
|
|
2731
3086
|
- [ ] 페이지에 `color="primary"` 채움 버튼이 **1개뿐**인가 (`danger` 채움도 1개, `SDropdownButton` 포함)
|
|
2732
3087
|
- [ ] 버튼 `size` 가 위치 규칙과 맞는가 (행 내부 `xs` / 화면 액션 `sm` / 모달 푸터 `md`)
|
|
2733
3088
|
- [ ] 파괴 액션의 채움/outline 이 **적용 범위 + 위험도** 기준과 맞는가
|
|
@@ -3476,7 +3831,7 @@ export interface SCalendarEventGroup {
|
|
|
3476
3831
|
| `defaultMonth?` | `number` | — | 처음 보여줄 월 1-12 (비제어). 없으면 오늘 |
|
|
3477
3832
|
| `selectable?` | `boolean` | `false` | 날짜 칸을 눌러 고를 수 있게 한다. 기본은 읽기 전용 판이다 — 켜야 칸에 커서·포커스·키보드(Enter·Space)가 붙고 `onValueChange` 가 온다. 일정 버튼과 "N개 더보기" 는 이 값과 무관하게 항상 눌린다. |
|
|
3478
3833
|
| `value?` | `string \| null` | — | 선택 날짜 (YYYY-MM-DD). `selectable` 과 별개로 강조만 주고 싶을 때도 쓴다 |
|
|
3479
|
-
| `events?` | `SCalendarBoardEvent[]` | — |
|
|
3834
|
+
| `events?` | `SCalendarBoardEvent[]` | — | 일정 목록. `date`(시작)로 칸에 배분되고, `endDate` 를 주면 그날까지 **하나의 막대로 이어져** 칸 경계를 넘어 그려진다. |
|
|
3480
3835
|
| `dayColors?` | `Record<string, SCalendarBoardDayColor>` | — | 날짜별 칸 배경. 키는 `YYYY-MM-DD`, 없는 날짜는 `'default'` 다. 그날의 성격(마감·완료·휴무 등)을 칸 전체로 알릴 때 쓴다 — 일정 하나의 색은 `events[].color` 다. |
|
|
3481
3836
|
| `dayContent?` | `Record<string, ReactNode>` | — | 날짜별 칸 본문. 키는 `YYYY-MM-DD`, 준 날짜는 **일정 자리를 통째로 이 내용이 차지한다** — 그날의 `events` 는 그리지 않고 목록 판도 열리지 않는다. 일정으로 표현되지 않는 것(재고 현황· 매출 요약 같은 그날치 요약)을 칸에 그대로 얹을 때 쓴다. |
|
|
3482
3837
|
| `todayLabel?` | `string` | `'오늘'` | 헤더 오른쪽 "오늘" 버튼 레이블. 빈 문자열이면 버튼을 그리지 않는다 |
|
|
@@ -3514,8 +3869,17 @@ export interface SCalendarBoardView {
|
|
|
3514
3869
|
export interface SCalendarBoardEvent {
|
|
3515
3870
|
/** 고유 식별자. 없으면 목록 순서로 key 를 만든다 */
|
|
3516
3871
|
id?: string;
|
|
3517
|
-
/** 날짜 (YYYY-MM-DD) */
|
|
3872
|
+
/** 시작 날짜 (YYYY-MM-DD). 하루짜리 일정은 이것만 준다 */
|
|
3518
3873
|
date: string;
|
|
3874
|
+
/**
|
|
3875
|
+
* 마지막 날 (YYYY-MM-DD, **그날 포함**). 주지 않거나 `date` 보다 앞서면 하루짜리로 본다.
|
|
3876
|
+
*
|
|
3877
|
+
* 여러 날에 걸친 일정은 칸 경계를 넘어 **하나의 막대로 이어져** 그려진다. 주 경계에서는 줄이
|
|
3878
|
+
* 바뀌므로 조각이 나뉘고, 그렇게 잘린 쪽 모서리는 평평해져 "앞/뒤로 이어진다" 가 읽힌다.
|
|
3879
|
+
*
|
|
3880
|
+
* 막대가 걸친 날은 **모두** 그 일정을 가진 날로 친다 — 목록 판과 `onValueChange` 에 함께 온다.
|
|
3881
|
+
*/
|
|
3882
|
+
endDate?: string;
|
|
3519
3883
|
/**
|
|
3520
3884
|
* 강조 레이블 (필수) — 예: `"[입고 예정]"`.
|
|
3521
3885
|
* 일정이 무엇인지 한눈에 알리는 부분이라 비워 두지 않는다.
|
|
@@ -7590,6 +7954,7 @@ export type SNumberInputSize = SFieldSize;
|
|
|
7590
7954
|
|------|------|---------|-------------|
|
|
7591
7955
|
| `background?` | `SPageBackground` | `'frame'` | 페이지 배경 표면. frame=흰 콘텐츠 면, neutral=옅은 회색 면, screen=앱 바탕. 스크롤바 처리도 여기 묶여 있다 — 셋 다 구분선+트랙 배경이고, 트랙 색만 neutral 에서 흰색이 된다. |
|
|
7592
7956
|
| `scrollEndSpacing?` | `boolean` | `false` | 스크롤 끝 여백. 마지막 항목이 창 하단에 붙어 "여기서 끝"이 안 읽히는 것을 막는다. **기본은 꺼져 있다.** 페이지 스크롤 자체가 예외이기 때문이다 — 대부분의 화면은 본문이 창을 꽉 채우고 스크롤은 각 영역 안에서 일어난다. 여백을 조건 없이 붙이면 내용이 화면에 거의 딱 맞는 페이지까지 그 여백 때문에 스크롤되게 만든다. 페이지가 실제로 스크롤되는 화면(`contentHeight="auto"` + 내용이 창보다 김)에서만 켠다. 페이지네이션처럼 끝을 알려주는 것이 이미 있으면 켜지 않는다. |
|
|
7957
|
+
| `padding?` | `SPagePadding` | `'default'` | 본문 안쪽 여백. 기본은 `default` — `--cmp-pageBody-padding-default` 를 사방에 넣는다. `none` 은 필터 바처럼 **가장자리까지 차는 블록이 맨 위에 서는** 화면용이다. 패딩을 끄면 그 아래 블록이 `p-[var(--cmp-pageBody-padding-default)]` 로 같은 토큰의 여백을 직접 잡는다 — `p-sd-*` 로 적으면 토큰이 바뀌어도 따라가지 않는다. 사방 패딩만 끄고 `scrollEndSpacing` 은 그대로다. (`SPopup`·`SSectionHeaderCard` 의 `padding="none"` 과 같은 관용구) |
|
|
7593
7958
|
| `overlayScrollbar?` | `boolean` | `false` | true면 네이티브 스크롤바를 숨기고 SPage 위에 오버레이 스크롤바를 얹는다. 스크롤바가 레이아웃 폭을 차지하지 않아 내부 콘텐츠 폭이 줄어들지 않는다. |
|
|
7594
7959
|
| `contentHeight?` | `SPageContentHeight` | `'auto'` | 본문 높이 모드. 기본은 `auto` — 콘텐츠가 흐르고 넘치면 페이지가 스크롤한다. `fill` 은 본문이 남은 높이를 정확히 채우고 페이지는 스크롤하지 않는다. 표가 자기 안에서 스크롤하고 페이지네이션이 하단에 고정되는 목록 화면용이다. 본문 루트에 `h-full` 이 먹으므로 `<div className="flex h-full min-h-0 flex-col">` + `<STable className="min-h-0 flex-1" />` 구성이 성립한다. `fill` 에서는 페이지가 스크롤하지 않으므로 `scrollEndSpacing` 이 무시된다. |
|
|
7595
7960
|
| `contentWidth?` | `SPageContentWidth` | `'full'` | 본문 가로 폭 모드. 기본은 `full` — 창이 넓어지면 본문도 같이 넓어진다(반응형). `fixed`·`fixedCenter` 는 본문을 **레이아웃 최소 폭일 때의 폭**으로 고정한다. 최소 폭 기준으로 그린 시안이 넓은 모니터에서 늘어나 성기어지는 것을 막는 모드다. 남는 폭은 `fixed` 면 오른쪽에, `fixedCenter` 면 좌우로 나뉘어 페이지 배경으로 남는다. 고정 폭은 GNB 폭·접힘을 반영해 저절로 따라오므로 앱이 숫자를 쓸 일은 없다. 창이 최소 폭보다 좁아지면 세 모드가 모두 같다 — 남는 폭이 없어 규칙이 꺼지고, 모자란 몫을 지금까지처럼 이 영역이 가로 스크롤로 삼킨다. 같은 열에 `SPageHeader` 가 있으면 헤더도 같은 폭·정렬을 따른다(제목과 본문의 좌우 끝이 맞는다). `SLayout` 밖에서는 기준이 될 최소 폭이 없으므로 무시된다. |
|
|
@@ -7608,6 +7973,12 @@ export type SPageHeaderVariant = (typeof PAGE_HEADER_VARIANTS)[number];
|
|
|
7608
7973
|
export type SPageBackground = (typeof PAGE_BACKGROUNDS)[number];
|
|
7609
7974
|
```
|
|
7610
7975
|
|
|
7976
|
+
### SPagePadding
|
|
7977
|
+
|
|
7978
|
+
```ts
|
|
7979
|
+
export type SPagePadding = (typeof PAGE_PADDINGS)[number];
|
|
7980
|
+
```
|
|
7981
|
+
|
|
7611
7982
|
### SPageContentHeight
|
|
7612
7983
|
|
|
7613
7984
|
```ts
|
|
@@ -7644,6 +8015,18 @@ export const PAGE_HEADER_VARIANTS = ['plain', 'bar'] as const;
|
|
|
7644
8015
|
export const PAGE_BACKGROUNDS = ['frame', 'neutral', 'screen'] as const;
|
|
7645
8016
|
```
|
|
7646
8017
|
|
|
8018
|
+
### PAGE_PADDINGS
|
|
8019
|
+
|
|
8020
|
+
```ts
|
|
8021
|
+
/**
|
|
8022
|
+
* 본문 안쪽 여백 모드.
|
|
8023
|
+
* - default: SPage 가 --cmp-pageBody-padding-default 로 사방 패딩을 넣는다
|
|
8024
|
+
* - none: 패딩을 끈다. 가장자리까지 차는 블록(필터 바 등)이 맨 위에 서고, 그 아래 블록이
|
|
8025
|
+
* 같은 토큰으로 여백을 직접 잡는 화면용. SPopup·SSectionHeaderCard 의 padding="none" 과 같은 관용구.
|
|
8026
|
+
*/
|
|
8027
|
+
export const PAGE_PADDINGS = ['default', 'none'] as const;
|
|
8028
|
+
```
|
|
8029
|
+
|
|
7647
8030
|
### PAGE_CONTENT_HEIGHTS
|
|
7648
8031
|
|
|
7649
8032
|
```ts
|