npm package: @medisolve-ai/medikit
Description: 메디솔브AI 공통 UI 라이브러리
Latest version: 0.3.0

# @medisolve-ai/medikit

메디솔브AI 공통 UI 라이브러리입니다. Radix UI + Tailwind CSS 기반이며, 모든 컴포넌트는 `forwardRef`를 지원합니다.

## 설치

```bash
pnpm add @medisolve-ai/medikit
```

React 18+, Tailwind CSS 3+ 가 필요합니다.

## 설정

### 1. Tailwind preset 등록

medikit은 디자인 토큰(색상, 타이포그래피 등)을 Tailwind preset으로 제공합니다. `tailwind.config.ts`에 등록하세요.

```ts
import { medikitPreset } from "@medisolve-ai/medikit/tailwind";

export default {
  content: [
    "./src/**/*.{ts,tsx}",
    // medikit 컴포넌트 내부 클래스가 빌드에 포함되도록 추가
    "./node_modules/@medisolve-ai/medikit/dist/**/*.js",
  ],
  presets: [medikitPreset],
};
```

### 2. 기본 스타일 import

루트 레이아웃(또는 엔트리 파일)에서 한 번만 불러오면 됩니다. CSS 변수, 기본 폰트 등이 설정됩니다.

```ts
import "@medisolve-ai/medikit/styles";
```

### 3. 컴포넌트 사용

```tsx
import { Button, Input, Label } from "@medisolve-ai/medikit";
```

---

## 컴포넌트

### Button

다양한 variant, color, size, shape 조합을 지원하는 버튼입니다.

```tsx
<Button variant="solid" color="blue" size="lg">확인</Button>
<Button variant="outlined" color="black" size="sm">취소</Button>
<Button variant="solid" color="blue" isLoading>저장 중...</Button>
<Button variant="filled" color="red" leftIcon={<WarningIcon />}>삭제</Button>
```

`asChild`를 사용하면 자식 요소에 버튼 스타일을 위임할 수 있습니다.

```tsx
<Button asChild variant="solid" color="blue">
  <a href="/next">다음 페이지</a>
</Button>
```


| Prop        | Type                                                                 | Default     | 설명                        |
| ----------- | -------------------------------------------------------------------- | ----------- | ------------------------- |
| `variant`   | `'solid' | 'filled' | 'outlined' | 'text' | 'icon' | 'fillOutlined'` | `'solid'`   | 버튼 스타일                    |
| `color`     | `'blue' | 'red' | 'black' | 'gray'`                                  | `'blue'`    | 버튼 색상                     |
| `size`      | `'lg' | 'md' | 'sm'`                                                 | `'lg'`      | 버튼 크기                     |
| `shape`     | `'default' | 'circle' | 'round'`                                     | `'default'` | 버튼 모양                     |
| `isLoading` | `boolean`                                                            | `false`     | 로딩 스피너 표시. `true`이면 클릭 불가 |
| `leftIcon`  | `ReactNode`                                                          | -           | 왼쪽 아이콘                    |
| `rightIcon` | `ReactNode`                                                          | -           | 오른쪽 아이콘                   |
| `asChild`   | `boolean`                                                            | `false`     | 자식 요소에 props 위임           |


네이티브 `<button>` 속성도 모두 사용 가능합니다.

---

### Input

텍스트 입력 필드입니다. 오른쪽에 아이콘 슬롯을 제공합니다.

```tsx
<Input placeholder="이름을 입력하세요" />
<Input icon={<SearchIcon />} onIconClick={handleSearch} />
<Input isError placeholder="필수 항목입니다" />
```


| Prop                 | Type         | Default | 설명                 |
| -------------------- | ------------ | ------- | ------------------ |
| `icon`               | `ReactNode`  | -       | 오른쪽 아이콘            |
| `onIconClick`        | `() => void` | -       | 아이콘 클릭 핸들러         |
| `isError`            | `boolean`    | `false` | 에러 스타일 (빨간 테두리)    |
| `containerClassName` | `string`     | -       | 외부 컨테이너 className  |
| `inputClassName`     | `string`     | -       | input 요소 className |


네이티브 `<input>` 속성도 모두 사용 가능합니다.

---

### Textarea

멀티라인 텍스트 입력입니다.

```tsx
<Textarea placeholder="내용을 입력하세요" rows={5} />
<Textarea isError />
```


| Prop                 | Type      | Default | 설명                    |
| -------------------- | --------- | ------- | --------------------- |
| `isError`            | `boolean` | `false` | 에러 스타일                |
| `containerClassName` | `string`  | -       | 외부 컨테이너 className     |
| `textareaClassName`  | `string`  | -       | textarea 요소 className |


---

### Label

폼 라벨입니다. `required`를 넘기면 파란 별표()가 표시됩니다.

```tsx
<Label label="이름" required>
  <Input placeholder="이름을 입력하세요" />
</Label>
```


| Prop                 | Type        | Default | 설명                |
| -------------------- | ----------- | ------- | ----------------- |
| `label`              | `ReactNode` | (필수)    | 라벨 텍스트            |
| `required`           | `boolean`   | `false` | 필수 표시 ()          |
| `containerClassName` | `string`    | -       | 외부 컨테이너 className |


---

### ComboBox

검색 가능한 드롭다운 선택입니다. 단일 선택과 다중 선택을 모두 지원합니다.

```tsx
// 단일 선택
<ComboBox
  options={[
    { value: 'apple', label: '사과', keywords: ['apple', '사과'] },
    { value: 'banana', label: '바나나', keywords: ['banana', '바나나'] },
  ]}
  value={value}
  onValueChange={setValue}
  placeholder="과일 선택"
/>

// 다중 선택
<ComboBox
  multiSelect
  options={options}
  value={selectedValues}
  onValueChange={setSelectedValues}
  hasAllOption
/>
```


| Prop            | Type                                  | Default           | 설명                      |
| --------------- | ------------------------------------- | ----------------- | ----------------------- |
| `options`       | `ComboboxOption[]`                    | (필수)              | 선택 옵션 목록                |
| `value`         | `string | string[]`                   | (필수)              | 선택된 값                   |
| `onValueChange` | `(value) => void`                     | (필수)              | 값 변경 콜백                 |
| `multiSelect`   | `boolean`                             | `false`           | 다중 선택 모드                |
| `hasAllOption`  | `boolean`                             | `true`            | "전체" 선택 옵션 표시 (다중 선택 시) |
| `hasSearch`     | `boolean`                             | `true`            | 검색 입력 표시                |
| `placeholder`   | `string`                              | `'선택'`            | 플레이스홀더 텍스트              |
| `filter`        | `(value, search, keywords) => number` | 키워드 매칭            | 커스텀 필터 함수               |
| `hasCheckIcon`  | `boolean`                             | `true`            | 체크 아이콘 표시               |
| `emptyText`     | `string`                              | `'일치하는 항목이 없습니다'` | 결과 없음 텍스트               |
| `renderOption`  | `(option) => ReactNode`               | -                 | 커스텀 옵션 렌더링              |


**ComboboxOption**

```ts
interface ComboboxOption {
  value: string; // 고유 값
  label: string; // 표시 텍스트
  selectedLabel?: string; // 선택됐을 때 트리거에 표시할 텍스트
  keywords?: string[]; // 검색 키워드 (label 외 추가 검색어)
}
```

---

### InfiniteComboBox

서버 사이드 검색 + 무한 스크롤을 지원하는 ComboBox입니다. 대량 데이터를 다룰 때 사용합니다.

```tsx
<InfiniteComboBox
  options={data?.pages.flatMap((p) => p.items) ?? []}
  value={value}
  onValueChange={setValue}
  onSearch={setSearchQuery}
  onLoadMore={() => fetchNextPage()}
  hasNextPage={hasNextPage}
  isFetchingNextPage={isFetchingNextPage}
  isLoading={isLoading}
  debounceMs={300}
/>
```

ComboBox의 모든 Props를 포함하며, 추가로:


| Prop                 | Type                       | Default | 설명                 |
| -------------------- | -------------------------- | ------- | ------------------ |
| `onSearch`           | `(search: string) => void` | -       | 검색어 변경 콜백 (서버 검색용) |
| `onLoadMore`         | `() => void`               | -       | 다음 페이지 로드 콜백       |
| `onOpenChange`       | `(open: boolean) => void`  | -       | 드롭다운 열림/닫힘 콜백      |
| `hasNextPage`        | `boolean`                  | -       | 다음 페이지 존재 여부       |
| `isFetchingNextPage` | `boolean`                  | -       | 다음 페이지 로딩 중        |
| `isLoading`          | `boolean`                  | -       | 초기 로딩 중            |
| `debounceMs`         | `number`                   | `300`   | 검색 디바운스 (ms)       |
| `fallbackLabel`      | `string`                   | -       | 옵션에 없는 값의 대체 라벨    |


---

### Modal

Portal 기반 모달입니다. `isOpen`으로 열림/닫힘을 제어합니다.

```tsx
<Modal isOpen={isOpen} onBackdropClick={() => setIsOpen(false)}>
  <h2>모달 제목</h2>
  <p>모달 내용</p>
</Modal>
```


| Prop              | Type         | Default | 설명       |
| ----------------- | ------------ | ------- | -------- |
| `isOpen`          | `boolean`    | (필수)    | 모달 표시 여부 |
| `hasBackdrop`     | `boolean`    | `true`  | 배경 딤드 처리 |
| `onBackdropClick` | `() => void` | -       | 배경 클릭 콜백 |


열려 있는 동안 body 스크롤이 자동으로 잠깁니다.

---

### BaseModal

Modal에 헤더(제목 + 닫기 버튼)가 포함된 버전입니다.

```tsx
<BaseModal
  isOpen={isOpen}
  onClose={() => setIsOpen(false)}
  title="환자 정보"
  description="필수 항목을 입력하세요"
>
  <form>...</form>
</BaseModal>
```


| Prop                         | Type         | Default | 설명           |
| ---------------------------- | ------------ | ------- | ------------ |
| `title`                      | `string`     | -       | 모달 제목        |
| `description`                | `string`     | -       | 제목 아래 설명 텍스트 |
| `onClose`                    | `() => void` | (필수)    | 닫기 콜백        |
| `hasCloseButton`             | `boolean`    | `true`  | X 버튼 표시      |
| `shouldCloseOnBackdropClick` | `boolean`    | `false` | 배경 클릭으로 닫기   |


Modal의 모든 Props를 상속합니다.

---

### ConfirmModal

확인/취소 버튼이 포함된 확인 모달입니다.

```tsx
<ConfirmModal
  isOpen={isOpen}
  title="정말 삭제하시겠습니까?"
  description="삭제된 데이터는 복구할 수 없습니다."
  variant="warning"
  leftButton={{ label: "취소", onClick: () => setIsOpen(false) }}
  rightButton={{ label: "삭제", onClick: handleDelete }}
/>
```


| Prop          | Type                                  | Default   | 설명                |
| ------------- | ------------------------------------- | --------- | ----------------- |
| `title`       | `string`                              | (필수)      | 제목                |
| `description` | `string`                              | (필수)      | 설명                |
| `variant`     | `'warning' | 'check'`                 | `'check'` | 아이콘 타입            |
| `leftButton`  | `ButtonProps & { label?: ReactNode }` | -         | 왼쪽 버튼 (기본: "취소")  |
| `rightButton` | `ButtonProps & { label?: ReactNode }` | -         | 오른쪽 버튼 (기본: "확인") |


---

### Table

테이블 컴포넌트입니다. 조합형(composable) API로 구성합니다.

```tsx
<Table>
  <TableHeader>
    <TableRow>
      <TableHead>이름</TableHead>
      <TableHead>나이</TableHead>
      <TableHead>연락처</TableHead>
    </TableRow>
  </TableHeader>
  <TableBody>
    {patients.map((patient) => (
      <TableRow key={patient.id}>
        <TableCell>{patient.name}</TableCell>
        <TableCell>{patient.age}</TableCell>
        <TableCell>{patient.phone}</TableCell>
      </TableRow>
    ))}
  </TableBody>
</Table>
```

데이터가 없을 때 빈 상태를 표시하려면 `TableScrollArea`를 사용하세요.

```tsx
<TableScrollArea>
  <TableScrollAreaViewport>
    <Table>...</Table>
  </TableScrollAreaViewport>
  {items.length === 0 && (
    <TableScrollAreaEmpty>데이터가 없습니다</TableScrollAreaEmpty>
  )}
</TableScrollArea>
```


| 컴포넌트                      | HTML 요소     | 설명                |
| ------------------------- | ----------- | ----------------- |
| `Table`                   | `<table>`   | 루트                |
| `TableHeader`             | `<thead>`   | 헤더 영역 (파란 배경)     |
| `TableBody`               | `<tbody>`   | 본문 영역             |
| `TableFooter`             | `<tfoot>`   | 푸터 영역             |
| `TableRow`                | `<tr>`      | 행                 |
| `TableHead`               | `<th>`      | 헤더 셀              |
| `TableCell`               | `<td>`      | 본문 셀              |
| `TableCaption`            | `<caption>` | 캡션                |
| `TableScrollArea`         | `<div>`     | 스크롤 래퍼            |
| `TableScrollAreaViewport` | `<div>`     | 스크롤 뷰포트           |
| `TableScrollAreaEmpty`    | `<div>`     | 빈 상태 오버레이 (중앙 정렬) |


모든 컴포넌트는 해당 HTML 요소의 네이티브 속성을 그대로 받습니다.

---

### Dialog

Radix UI 기반 다이얼로그입니다. 접근성(aria)이 자동 처리됩니다.

```tsx
<Dialog>
  <DialogTrigger asChild>
    <Button>열기</Button>
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>제목</DialogTitle>
      <DialogDescription>설명 텍스트</DialogDescription>
    </DialogHeader>
    <p>내용</p>
    <DialogFooter>
      <DialogClose asChild>
        <Button variant="outlined" color="black">
          닫기
        </Button>
      </DialogClose>
    </DialogFooter>
  </DialogContent>
</Dialog>
```


| 컴포넌트                | 설명         |
| ------------------- | ---------- |
| `Dialog`            | 루트 (상태 관리) |
| `DialogTrigger`     | 열기 트리거     |
| `DialogContent`     | 모달 콘텐츠     |
| `DialogHeader`      | 헤더 영역      |
| `DialogFooter`      | 푸터 영역      |
| `DialogTitle`       | 제목         |
| `DialogDescription` | 설명         |
| `DialogClose`       | 닫기 트리거     |
| `DialogOverlay`     | 배경 오버레이    |
| `DialogPortal`      | Portal     |


---

### Tooltip

호버 시 설명 텍스트를 표시합니다. 앱 루트에 `TooltipProvider`를 감싸야 합니다.

```tsx
// 루트에서 한 번 감싸기
<TooltipProvider>
  <App />
</TooltipProvider>

// 사용
<Tooltip>
  <TooltipTrigger>마우스를 올려보세요</TooltipTrigger>
  <TooltipContent>툴팁 내용</TooltipContent>
</Tooltip>
```


| 컴포넌트              | 설명                                                 |
| ----------------- | -------------------------------------------------- |
| `TooltipProvider` | 루트 Provider. `delayDuration`으로 표시 지연 설정 (기본 100ms) |
| `Tooltip`         | 개별 툴팁 루트                                           |
| `TooltipTrigger`  | 호버 대상                                              |
| `TooltipContent`  | 툴팁 내용. `sideOffset`, `side` 등으로 위치 조정              |


---

### Popover

클릭 시 팝오버를 표시합니다.

```tsx
<Popover>
  <PopoverTrigger asChild>
    <Button>옵션</Button>
  </PopoverTrigger>
  <PopoverContent>
    <p>팝오버 내용</p>
  </PopoverContent>
</Popover>
```


| 컴포넌트             | 설명       |
| ---------------- | -------- |
| `Popover`        | 루트       |
| `PopoverTrigger` | 클릭 대상    |
| `PopoverContent` | 팝오버 내용   |
| `PopoverAnchor`  | 위치 기준 앵커 |
| `PopoverClose`   | 닫기 트리거   |


---

### HoverCard

호버 시 카드를 표시합니다.

```tsx
<HoverCard>
  <HoverCardTrigger>홍길동</HoverCardTrigger>
  <HoverCardContent>
    <p>환자 상세 정보...</p>
  </HoverCardContent>
</HoverCard>
```

---

### Command

커맨드 팔레트(검색 + 선택)입니다. [cmdk](https://github.com/pacocoursey/cmdk) 기반입니다.

```tsx
<Command>
  <CommandInput placeholder="검색..." />
  <CommandList>
    <CommandEmpty>결과 없음</CommandEmpty>
    <CommandGroup heading="환자">
      <CommandItem onSelect={() => navigate("/patient/1")}>홍길동</CommandItem>
      <CommandItem onSelect={() => navigate("/patient/2")}>김철수</CommandItem>
    </CommandGroup>
  </CommandList>
</Command>
```

다이얼로그로 사용하려면 `CommandDialog`를 사용하세요.

```tsx
<CommandDialog open={open} onOpenChange={setOpen}>
  <CommandInput placeholder="명령어 입력..." />
  <CommandList>
    <CommandItem>항목</CommandItem>
  </CommandList>
</CommandDialog>
```


| 컴포넌트               | 설명                                |
| ------------------ | --------------------------------- |
| `Command`          | 루트. `filter`, `shouldFilter` 등 지원 |
| `CommandDialog`    | 다이얼로그 형태                          |
| `CommandInput`     | 검색 입력                             |
| `CommandList`      | 항목 목록                             |
| `CommandEmpty`     | 결과 없음 표시                          |
| `CommandGroup`     | 항목 그룹. `heading`으로 제목 설정          |
| `CommandItem`      | 개별 항목. `onSelect` 콜백              |
| `CommandSeparator` | 구분선                               |
| `CommandShortcut`  | 단축키 텍스트                           |


---

### AsyncBoundary

Suspense + ErrorBoundary를 하나로 합친 컴포넌트입니다.

```tsx
<AsyncBoundary
  pendingFallback={<Loading />}
  errorFallback={<p>오류가 발생했습니다</p>}
  delay={200}
>
  <SuspenseComponent />
</AsyncBoundary>
```


| Prop              | Type        | Default | 설명                               |
| ----------------- | ----------- | ------- | -------------------------------- |
| `pendingFallback` | `ReactNode` | `null`  | 로딩 중 표시할 UI                      |
| `errorFallback`   | `ReactNode` | `null`  | 에러 발생 시 표시할 UI                   |
| `delay`           | `number`    | -       | 로딩 UI 표시 지연 (ms). 짧은 로딩에서 깜빡임 방지 |


`react-error-boundary`의 Props도 함께 사용 가능합니다 (`onReset`, `resetKeys` 등).

---

### DeferredComponent

지정된 시간 후에 children을 렌더링합니다. 빠른 로딩에서 UI 깜빡임을 방지합니다.

```tsx
<Suspense
  fallback={
    <DeferredComponent delay={200}>
      <Loading />
    </DeferredComponent>
  }
>
  <LazyComponent />
</Suspense>
```


| Prop    | Type     | Default | 설명             |
| ------- | -------- | ------- | -------------- |
| `delay` | `number` | `200`   | 렌더링 지연 시간 (ms) |


---

### Loading

SVG 기반 스피너입니다. 부모의 `color`를 상속합니다.

```tsx
<Loading />
<Loading className="w-10 h-10 text-primary" />
```

---

### Spacing

요소 간 간격을 만드는 빈 div입니다.

```tsx
<Title>제목</Title>
<Spacing size={16} />
<Content>내용</Content>

{/* 수평 간격 */}
<Spacing direction="horizontal" size={8} />
```


| Prop        | Type                        | Default      | 설명         |
| ----------- | --------------------------- | ------------ | ---------- |
| `size`      | `number`                    | (필수)         | 간격 크기 (px) |
| `direction` | `'vertical' | 'horizontal'` | `'vertical'` | 간격 방향      |


---

### Divider

구분선입니다.

```tsx
<Divider />
<Divider direction="vertical" thickness="2px" color="bg-ms-gray-400" />
```


| Prop        | Type                        | Default            | 설명               |
| ----------- | --------------------------- | ------------------ | ---------------- |
| `direction` | `'horizontal' | 'vertical'` | `'horizontal'`     | 방향               |
| `color`     | `string`                    | `'bg-ms-gray-200'` | Tailwind 배경색 클래스 |
| `thickness` | `string`                    | `'1px'`            | 두께               |


---

### Menu

사이드바 메뉴입니다. localStorage로 접힘/펼침 상태를 자동 저장합니다.

```tsx
import { Menu } from '@medisolve-ai/medikit';
import { Link, useLocation } from 'react-router-dom';

const Sidebar = () => {
  const { pathname } = useLocation();

  return (
    <Menu
      menus={menuConfig}
      currentPath={pathname}
      linkAs={Link}
      logo={<img src="/logo.svg" alt="Logo" />}
    />
  );
};
```

| Prop | Type | Default | 설명 |
| --- | --- | --- | --- |
| `menus` | `MenuConfig[]` | (필수) | 메뉴 설정 배열 |
| `currentPath` | `string` | (필수) | 현재 경로 (활성 메뉴 판별용) |
| `linkAs` | `ElementType` | `'a'` | 링크 컴포넌트 (`Link` 등) |
| `logo` | `ReactNode` | - | 로고 영역 (펼침 상태에서만 표시) |
| `onMenuClick` | `(href, e) => void` | - | 메뉴 클릭 핸들러 |
| `onOpenChange` | `(open) => void` | - | 열림/닫힘 변경 콜백 |

**MenuConfig**

```ts
interface MenuConfig {
  name: string;       // 메뉴 이름
  href: string;       // 메뉴 경로
  disabled?: boolean; // 비활성화
  hidden?: boolean;   // 숨김
  icon?: ReactNode;   // 아이콘 (접힌 상태에서 표시)
  menus?: MenuConfig[]; // 하위 메뉴
}
```

접힌 상태에서는 아이콘만 표시되고, 호버 시 Tooltip으로 메뉴 이름이 나타납니다.

---

### Header

상단 네비게이션 헤더입니다. 시계, 지점명, 사용자명, 로그아웃 버튼이 내장되어 있습니다.

```tsx
import { Header, Logo } from '@medisolve-ai/medikit';
import { Link, useLocation } from 'react-router-dom';

const AppHeader = () => {
  const { pathname } = useLocation();
  const { userInfo } = useAuth();

  return (
    <Header
      logo={<Logo service="centurionSay" mode="prod" />}
      onLogoClick={() => navigate('/')}
      menus={menuConfig}
      currentPath={pathname}
      linkAs={Link}
      branchName={`${userInfo.branch.headquarterName} ${userInfo.branch.name}`}
      userName={userInfo.name}
      onLogout={handleLogout}
    />
  );
};
```

| Prop | Type | Default | 설명 |
| --- | --- | --- | --- |
| `logo` | `ReactNode` | - | 좌측 로고 |
| `onLogoClick` | `() => void` | - | 로고 클릭 핸들러 |
| `menus` | `MenuConfig[]` | `[]` | 네비게이션 메뉴 설정 |
| `currentPath` | `string` | `''` | 현재 경로 (활성 메뉴 판별용) |
| `linkAs` | `ElementType` | `'a'` | 링크 컴포넌트 (`Link` 등) |
| `onMenuClick` | `(href, e) => void` | - | 메뉴 클릭 핸들러 |
| `branchName` | `string` | - | 병원/지점 이름 |
| `userName` | `string` | - | 사용자 이름 |
| `onLogout` | `() => void` | - | 로그아웃 핸들러 |

**Menu + Header 조합 예시 (레이아웃)**

```tsx
import { Header, Menu, Logo } from '@medisolve-ai/medikit';
import { Link, useLocation, Outlet } from 'react-router-dom';

const MainLayout = () => {
  const { pathname } = useLocation();
  const { userInfo } = useAuth();

  return (
    <div className="flex min-h-screen w-full">
      <aside>
        <Menu
          menus={menuConfig}
          currentPath={pathname}
          linkAs={Link}
          logo={<Logo />}
        />
      </aside>
      <section className="flex flex-1 flex-col">
        <header className="h-[57px]">
          <Header
            logo={<Logo />}
            menus={menuConfig}
            currentPath={pathname}
            linkAs={Link}
            branchName={userInfo.branch.name}
            userName={userInfo.name}
            onLogout={handleLogout}
          />
        </header>
        <main className="flex-1">
          <Outlet />
        </main>
      </section>
    </div>
  );
};
```

---

### Checkbox

Radix UI 기반 체크박스입니다.

```tsx
<Checkbox />
<Checkbox defaultChecked />
<Checkbox disabled />
```

Radix UI `Checkbox`의 모든 Props를 그대로 받습니다.

---

### Calendar

`react-day-picker` 기반 캘린더입니다. 한국어 요일·월 포맷이 기본 적용됩니다.

```tsx
<Calendar
  mode="single"
  selected={date}
  onSelect={setDate}
/>

{/* 범위 선택 */}
<Calendar
  mode="range"
  selected={dateRange}
  onSelect={setDateRange}
/>
```

`react-day-picker`의 `DayPicker` Props를 그대로 받습니다.

---

### DatePicker

날짜 선택 Popover 컴포넌트입니다.

```tsx
<DatePicker
  selected={date}
  onSelect={setDate}
  placeholder="날짜 선택"
/>
```


| Prop                   | Type                            | Default      | 설명               |
| ---------------------- | ------------------------------- | ------------ | ---------------- |
| `selected`             | `Date`                          | -            | 선택된 날짜           |
| `onSelect`             | `(date: Date \| undefined) => void` | -        | 날짜 선택 핸들러        |
| `placeholder`          | `string`                        | `'날짜 선택'`   | 플레이스홀더 텍스트       |
| `textFormat`           | `string`                        | `'yyyy.MM.dd'` | 날짜 표시 포맷       |
| `shouldCloseOnSelect`  | `boolean`                       | `true`       | 선택 후 자동 닫기       |
| `readOnly`             | `boolean`                       | `false`      | 읽기 전용            |
| `calendarProps`        | `DayPickerProps`                | -            | Calendar에 전달할 props |


### DateRangePicker

날짜 범위 선택 컴포넌트입니다.

```tsx
<DateRangePicker
  isOpen={isOpen}
  displayedRange={dateRange}
  handleSelect={handleSelect}
  handleOpenChange={setIsOpen}
/>
```


| Prop               | Type                               | Default                            | 설명               |
| ------------------ | ---------------------------------- | ---------------------------------- | ---------------- |
| `isOpen`           | `boolean`                          | (필수)                               | 팝오버 열림 상태        |
| `displayedRange`   | `DateRange`                        | -                                  | 선택된 날짜 범위        |
| `handleSelect`     | `SelectRangeEventHandler`          | (필수)                               | 날짜 선택 핸들러        |
| `handleOpenChange` | `(open: boolean) => void`          | (필수)                               | 열림/닫힘 핸들러        |
| `placeholder`      | `{ from: string; to: string }`     | `{ from: '시작일 선택', to: '종료일 선택' }` | 플레이스홀더           |
| `textFormat`       | `string`                           | `'yyyy.MM.dd'`                     | 날짜 표시 포맷         |
| `readOnly`         | `boolean`                          | `false`                            | 읽기 전용            |


---

### Footer

하단 회사 정보 영역입니다. 사업자등록번호, 대표자명, 주소, 이메일, 저작권 정보가 표시됩니다.

```tsx
<Footer version="1.0.0" />
```


| Prop      | Type     | Default | 설명       |
| --------- | -------- | ------- | -------- |
| `version` | `string` | -       | 앱 버전 표시 |


네이티브 `<footer>` 속성도 모두 사용 가능합니다.

---

### Logo

서비스별·환경별 로고 컴포넌트입니다. `service`와 `mode`를 지정하면 해당 로고가 렌더링됩니다.

```tsx
<Logo service="centurionSay" mode="prod" />
<Logo service="centurionBay" mode="demo" />
<Logo service="centurionAdmin" mode="beta" />
```


| Prop      | Type                                                          | Default | 설명   |
| --------- | ------------------------------------------------------------- | ------- | ---- |
| `service` | `'centurion' \| 'centurionSay' \| 'centurionBay' \| 'centurionAdmin'` | (필수)  | 서비스 |
| `mode`    | `'prod' \| 'demo' \| 'beta'`                                  | (필수)  | 환경   |


SVG `<svg>` 속성도 모두 사용 가능합니다. 개별 로고 컴포넌트(`CenturionProdLogo`, `SayDemoLogo` 등)도 직접 import하여 사용할 수 있습니다.

---

### Icons

기본 아이콘 세트입니다. 모두 SVG 기반이며 `className`으로 크기와 색상을 조절합니다.

```tsx
import { SearchIcon, CloseIcon, ChevronIcon } from '@medisolve-ai/medikit';

<SearchIcon className="w-5 h-5" />
<CloseIcon className="w-4 h-4 text-ms-gray-600" />
<ChevronIcon direction="bottom" />
<ArrowIcon direction="left" />
<CheckIcon />
<CheckCircleIcon width={40} height={40} />
<WarningIcon width={40} height={40} />
```


| 아이콘               | 설명                                                      |
| ----------------- | ------------------------------------------------------- |
| `ArrowIcon`       | 화살표. `direction`: `'top' | 'right' | 'bottom' | 'left'` |
| `ChevronIcon`     | 꺾쇠. `direction`: `'top' | 'right' | 'bottom' | 'left'`  |
| `CloseIcon`       | X 닫기                                                    |
| `SearchIcon`      | 돋보기 검색                                                  |
| `CheckIcon`       | 체크마크                                                    |
| `CheckCircleIcon` | 원형 체크                                                   |
| `WarningIcon`     | 경고 원형 (연한 빨강 배경 + 빨강 느낌표)                              |
| `MoreIcon`        | 세로 점 3개 (더보기)                                           |
| `ResetIcon`       | 새로고침 화살표                                                 |
| `ErrorIcon`       | 에러 원형 (빨강 배경 + 흰색 느낌표)                                  |
| `CalendarIcon`    | 달력                                                        |


---

### Toggle

ON/OFF 토글 스위치입니다. 커스텀 라벨을 지원합니다.

```tsx
<Toggle checked={isOn} onChange={setIsOn} />
<Toggle checked={isOn} onChange={setIsOn} label={{ on: '노출', off: '미노출' }} />
<Toggle disabled />
```


| Prop       | Type                           | Default                     | 설명        |
| ---------- | ------------------------------ | --------------------------- | --------- |
| `checked`  | `boolean`                      | `false`                     | 토글 상태     |
| `onChange`  | `(checked: boolean) => void`   | -                           | 상태 변경 핸들러 |
| `label`    | `{ on: string; off: string }`  | `{ on: 'ON', off: 'OFF' }` | 라벨 텍스트    |
| `disabled` | `boolean`                      | `false`                     | 비활성화      |


---

### Tabs

탭 네비게이션입니다. 선택된 탭 하단에 파란 인디케이터가 표시됩니다.

```tsx
<Tabs
  tabs={[
    { id: 'monitoring', name: '상담 모니터링' },
    { id: 'customer', name: '고객 설정' },
    { id: 'ai', name: 'AI 상담 설정' },
  ]}
  selectedTab={selectedTab}
  onSelectTab={setSelectedTab}
/>
```


| Prop          | Type                       | Default | 설명         |
| ------------- | -------------------------- | ------- | ---------- |
| `tabs`        | `Tab[]`                    | (필수)    | 탭 목록       |
| `selectedTab` | `string`                   | (필수)    | 선택된 탭 ID   |
| `onSelectTab` | `(tab: string) => void`    | (필수)    | 탭 선택 핸들러   |
| `disabled`    | `boolean`                  | `false` | 비활성화       |

**Tab**

```ts
interface Tab {
  id: string;       // 고유 ID
  name: string;     // 표시 텍스트
  testId?: string;  // data-testid
}
```

---

### RadioGroup

Radix UI 기반 라디오 그룹입니다.

```tsx
<RadioGroup value={value} onValueChange={setValue}>
  <div className="flex items-center gap-2">
    <RadioGroupItem value="option1" id="option1" />
    <label htmlFor="option1">옵션 1</label>
  </div>
  <div className="flex items-center gap-2">
    <RadioGroupItem value="option2" id="option2" />
    <label htmlFor="option2">옵션 2</label>
  </div>
</RadioGroup>
```

Radix UI `RadioGroup`의 모든 Props를 그대로 받습니다.

---

### Pagination

페이지네이션 컴포넌트입니다. 중앙에 현재 페이지를 두고 양쪽에 말줄임(…)을 자동으로 표시합니다.

```tsx
<Pagination
  totalPages={20}
  currentPage={currentPage}
  onPageChange={setCurrentPage}
/>
```


| Prop           | Type                       | Default | 설명                     |
| -------------- | -------------------------- | ------- | ---------------------- |
| `totalPages`   | `number`                   | (필수)    | 총 페이지 수                |
| `currentPage`  | `number`                   | (필수)    | 현재 페이지 (1부터)           |
| `onPageChange` | `(page: number) => void`   | (필수)    | 페이지 변경 핸들러             |
| `visiblePages` | `number`                   | `9`     | 표시할 페이지 수 (최소 7, 홀수만) |


---

### ActionButton

테이블 행의 "더보기" 버튼입니다. 클릭 시 상세/삭제 팝오버가 나타납니다.

```tsx
<ActionButton
  onDetailClick={() => navigate(`/detail/${id}`)}
  onDeleteClick={() => handleDelete(id)}
/>
<ActionButton onDetailClick={handleDetail} hasDeleteButton={false} />
```


| Prop              | Type                                            | Default        | 설명           |
| ----------------- | ----------------------------------------------- | -------------- | ------------ |
| `onDetailClick`   | `(e: MouseEvent) => void`                       | (필수)           | 상세 클릭 핸들러    |
| `onDeleteClick`   | `(e: MouseEvent) => void`                       | -              | 삭제 클릭 핸들러    |
| `hasDeleteButton` | `boolean`                                       | `true`         | 삭제 버튼 표시 여부  |
| `primaryLabel`    | `string`                                        | `'상세'`        | 첫 번째 버튼 라벨   |
| `deleteLabel`     | `string`                                        | `'삭제'`        | 삭제 버튼 라벨     |
| `className`       | `{ trigger?: string; content?: string }`        | -              | 스타일 오버라이드    |


---

### ResetButton

리셋(초기화) 버튼입니다. 클릭 시 아이콘이 1회전합니다.

```tsx
<ResetButton onClick={handleReset} />
<ResetButton onClick={handleReset} disabled />
```

네이티브 `<button>` 속성을 모두 사용 가능합니다. 50×50px 고정 크기이며, 클릭 시 아이콘이 1회전합니다.

---

### Notify

토스트 알림입니다. `react-toastify` 기반이며, 앱 루트에 `NotifyContainer`를 배치한 뒤 `notify` 함수로 호출합니다.

```tsx
// 루트에 한 번 배치
import { NotifyContainer } from '@medisolve-ai/medikit';
<NotifyContainer />

// 사용
import { notify } from '@medisolve-ai/medikit';
notify.success('저장되었습니다');
notify.error('오류가 발생했습니다');
```


| 함수               | 인자                               | 설명        |
| ---------------- | -------------------------------- | --------- |
| `notify.success` | `(message: string, duration?)` | 성공 알림     |
| `notify.error`   | `(message: string, duration?)` | 에러 알림     |

`duration` 기본값은 1500ms입니다.

**NotifyContainer Props**


| Prop        | Type     | Default | 설명             |
| ----------- | -------- | ------- | -------------- |
| `autoClose` | `number` | `2000`  | 자동 닫힘 시간 (ms) |
| `limit`     | `number` | `1`     | 동시 표시 최대 개수   |


---

## 디자인 토큰

### CSS 변수

medikit은 `--ms-*` CSS 변수로 디자인 토큰을 정의합니다. `@medisolve-ai/medikit/styles`를 import하면 자동 적용됩니다.

```css
/* Primary (제품별 오버라이드 대상) */
--ms-primary: #476cff;
--ms-primary-100: #f0f4ff;
--ms-primary-300: #dde6ff;
--ms-primary-500: #97b5ff;
--ms-primary-shadow: rgba(71, 108, 255, 0.3);

/* Red */
--ms-red: #ff4e51;
--ms-red-100: #ffefef;
--ms-red-300: #ffe1e1;
--ms-red-500: #ffc4c5;
--ms-red-shadow: rgba(255, 78, 81, 0.2);

/* Success */
--ms-success: var(--ms-primary);

/* Purple */
--ms-purple: #ac47ff;
--ms-purple-100: #f8f0ff;
--ms-purple-500: #bf71ff;

/* Hover */
--ms-hover-dark: #222943;

/* Base */
--ms-white: #ffffff;
--ms-black: #000000;
--ms-overlay: rgba(0, 0, 0, 0.6);

/* Gray Scale */
--ms-gray-100: #fbfbfb;
--ms-gray-200: #f3f3f3;
--ms-gray-300: #e3e3e3;
--ms-gray-400: #d6d6d6;
--ms-gray-500: #cfcfcf;
--ms-gray-600: #9f9f9f;
--ms-gray-700: #6e6e6e;
--ms-gray-800: #3e3e3e;

/* Semantic */
--ms-text-primary: var(--ms-black);
--ms-text-secondary: var(--ms-gray-800);
--ms-text-disabled: var(--ms-gray-600);
--ms-text-placeholder: var(--ms-gray-600);
--ms-border-default: var(--ms-gray-300);
--ms-border-focus: var(--ms-primary);
--ms-bg-page: var(--ms-white);
--ms-bg-subtle: var(--ms-gray-100);
--ms-bg-muted: var(--ms-gray-200);
```

### 제품별 테마 (data-product)

소비 프로젝트에서 `<html data-product="say">` 속성을 설정하면 `--ms-primary-*` 변수가 자동 오버라이드됩니다.

| 제품 | `--ms-primary` | `--ms-primary-500` | `--ms-primary-300` | `--ms-primary-100` |
|------|----------------|--------------------|--------------------|---------------------|
| Centurion/Day (기본) | #476CFF | #97B5FF | #DDE6FF | #F0F4FF |
| Say | #5442FF | #797CFF | #E2E7FF | #F4F5FF |
| Bay | #23A0FF | #9ED6FF | #D7EEFF | #EFF8FF |
| Charty | #6F21FF | #A290FF | #E9E6FF | #F4F3FF |
| Watch | #2CD1FF | #8AE3FF | #D2F2FF | #EDF9FF |

```html
<!-- 예시: Say 제품 -->
<html data-product="say">
```

### Tailwind 유틸리티

preset을 등록하면 아래처럼 사용 가능합니다.

```html
<div class="bg-primary text-ms-white">Primary 버튼</div>
<div class="border-ms-gray-300 text-ms-gray-700">회색 텍스트</div>
<div class="bg-red-100 text-red">에러 메시지</div>
```

### 타이포그래피

시맨틱 폰트 토큰이 제공됩니다. `text-typo-*` 클래스로 사용합니다.

패턴: `text-typo-{category}-{size}-{weight}`

| Category | Size | Line Height | Weights | 클래스 예시 |
|----------|------|-------------|---------|-----------|
| Headline L | 40px | 130% | 400/500/600 | `text-typo-headline-lg-500` |
| Headline M | 32px | 130% | 400/500/600 | `text-typo-headline-md-500` |
| Title L | 24px | 140% | 400/500/600 | `text-typo-title-lg-500` |
| Title M | 20px | 140% | 400/500/600 | `text-typo-title-md-500` |
| Title S | 18px | 150% | 400/500/600 | `text-typo-title-sm-500` |
| Heading M | 16px | 150% | 400/500/600 | `text-typo-heading-md-500` |
| Heading S | 14px | 150% | 400/500 | `text-typo-heading-sm-400` |
| Body | 14px | 160% | 400/500/600 | `text-typo-body-400` |
| Caption,Meta M | 12px | 130% | 400/500/600 | `text-typo-caption-md-500` |
| Caption,Meta S | 10px | 130% | 400/500 | `text-typo-meta-sm-400` |


---

## Hooks

### useCopyClipboard

클립보드 복사 기능을 제공합니다.

```tsx
const { copy } = useCopyClipboard();

<Button onClick={() => copy("복사할 텍스트")}>복사</Button>;
```

### useCallbackRef

항상 최신 콜백을 참조하는 안정적인 ref를 반환합니다. 의존성 배열에 콜백을 넣지 않아도 됩니다.

```tsx
const stableCallback = useCallbackRef(onChange);
```

---

## 유틸리티 함수

### 일반


| 함수                           | 설명                    | 예시                                           |
| ---------------------------- | --------------------- | -------------------------------------------- |
| `cn(...inputs)`              | clsx + tailwind-merge | `cn('px-4', isActive && 'bg-primary')`       |
| `parseDate(value)`           | 날짜 파싱                 | `parseDate('2024-01-15')`                    |
| `formatDateTime(date)`       | 날짜/시간 포맷              | `formatDateTime(new Date())`                 |
| `formatDateOnly(date)`       | 날짜만 포맷                | `formatDateOnly(new Date())`                 |
| `formatNumber(num)`          | 천 단위 콤마               | `formatNumber(1234567)` → `'1,234,567'`      |
| `removeNonNumeric(str)`      | 숫자만 남기기               | `removeNonNumeric('010-1234')` → `'0101234'` |
| `replaceHyphenWithDot(str)`  | 하이픈을 점으로              | `'2024-01-15'` → `'2024.01.15'`              |
| `replaceDotWithHyphen(str)`  | 점을 하이픈으로              | `'2024.01.15'` → `'2024-01-15'`              |
| `validateEmail(email)`       | 이메일 유효성 검사            | `validateEmail('a@b.com')` → `true`          |
| `isNonNullable(value)`       | null/undefined 체크     | 배열 필터링에 유용                                   |
| `removeEmptyParams(obj)`     | 빈 파라미터 제거             | API 호출 전 쿼리 정리                               |
| `sleep(ms)`                  | Promise 딜레이           | `await sleep(1000)`                          |
| `scrollToTop()`              | 최상단 스크롤               | 페이지 이동 시                                     |
| `scrollIntoViewIfNeeded(el)` | 요소가 보이도록 스크롤          | 포커스된 항목 표시                                   |


### 한국어 특화


| 함수                           | 설명        | 예시                                     |
| ---------------------------- | --------- | -------------------------------------- |
| `formatBirthToDot(birth)`    | 생년월일 포맷   | `'19900115'` → `'1990.01.15'`          |
| `formatPhone(phone)`         | 전화번호 포맷   | `'01012345678'` → `'010-1234-5678'`    |
| `formatTimeToKorean(time)`   | 시간 한국어 포맷 | `'14:30'` → `'오후 2시 30분'`              |
| `formatDuration(seconds)`    | 시간 길이 포맷  | `formatDuration(3661)` → `'1시간 1분 1초'` |
| `formatCurrency(amount)`     | 원화 포맷     | `formatCurrency(50000)` → `'50,000원'`  |
| `validateBirthDate(birth)`   | 생년월일 검증   | 6/8자리 숫자                               |
| `validatePhoneNumber(phone)` | 전화번호 검증   | 한국 전화번호                                |


---

## 라이선스

MIT