# 폰페이 Phase 4 — 파트너 콘솔 UI 구현 지침서

> 대상: VS Code (partner-ui, Vue 3 + TypeScript)
> 프로젝트 경로: `cryptoments-admin/partner-ui/`
> 선행 조건: Phase 1~3 완료 (DDL + common + core + open-api + partner-api)
> 패턴 참조: `AximView.vue` (연동 설정 탭), `AximPaymentsView.vue` (목록 테이블), `IntegrationSettingsView.vue` (탭 구조)
> 백엔드 API: `PartnerIntegrationController` (설정 CRUD), `PartnerPhonepayController` (세션 목록/상세)

---

## 범위

| 작업 | 파일 | 유형 |
|------|------|------|
| 폰페이 설정 탭 화면 | `views/partner/settings/PhonepayView.vue` | **신규** |
| 폰페이 세션 목록 화면 | `views/partner/deposits/PhonepaySessionsView.vue` | **신규** |
| API 서비스 — 설정 | `api/services/integration.service.ts` | **수정** |
| API 서비스 — 세션 | `api/services/deposit.service.ts` | **수정** |
| 연동 설정 탭 추가 | `views/partner/settings/IntegrationSettingsView.vue` | **수정** |
| 사이드바 메뉴 추가 | `utils/constants.ts` | **수정** |
| 라우터 추가 | `router/index.ts` | **수정** |
| 상태 색상/라벨 보강 | `utils/constants.ts` | **수정** (이미 있음 확인) |

**총 신규 2개 + 수정 5개**

---

## 1. API 서비스 — `integration.service.ts` 수정

경로: `src/api/services/integration.service.ts`

기존 `integrationService` 객체에 폰페이 설정 API 4개 추가:

```typescript
  // ── 폰페이 (BanqPipe) ──

  getPhonepay: () =>
    api.get<Record<string, unknown>>('/api/partner/integration/phonepay'),

  updatePhonepay: (data: Record<string, unknown>) =>
    api.put<Record<string, unknown>>('/api/partner/integration/phonepay', data),

  activatePhonepay: () =>
    api.post<Record<string, unknown>>('/api/partner/integration/phonepay/activate'),

  deactivatePhonepay: () =>
    api.post<Record<string, unknown>>('/api/partner/integration/phonepay/deactivate'),
```

기존 `getAxim`, `updateAxim` 뒤에 추가.

---

## 2. API 서비스 — `deposit.service.ts` 수정

경로: `src/api/services/deposit.service.ts`

기존 `depositService` 객체에 폰페이 세션 API 2개 추가:

```typescript
  // ── 폰페이 세션 ──

  getPhonepaySessions: (params: Record<string, unknown>) =>
    api.get<XPage<Record<string, unknown>>>('/api/partner/phonepay/sessions', params),

  getPhonepaySession: (id: number) =>
    api.get<Record<string, unknown>>(`/api/partner/phonepay/sessions/${id}`),
```

기존 `getAximPayments` 뒤에 추가.

---

## 3. 연동 설정 탭 추가 — `IntegrationSettingsView.vue` 수정

경로: `src/views/partner/settings/IntegrationSettingsView.vue`

### 3.1 TabKey 타입에 `phonepay` 추가

```typescript
type TabKey = 'api' | 'webhook' | 'telegram' | 'axim' | 'phonepay'
```

### 3.2 tabs 배열에 추가

```typescript
const tabs: { key: TabKey; label: string }[] = [
  { key: 'api', label: 'API 키' },
  { key: 'webhook', label: 'Webhook' },
  { key: 'telegram', label: 'Telegram' },
  { key: 'axim', label: 'Axim Pay' },
  { key: 'phonepay', label: '폰페이' },   // ← 추가
]
```

### 3.3 컴포넌트 lazy import 추가

```typescript
const PhonepaySection = defineAsyncComponent(() => import('./PhonepayView.vue'))
```

### 3.4 template에 탭 내용 추가

기존 `<AximSection v-if="activeTab === 'axim'" />` 아래에:

```html
    <PhonepaySection v-if="activeTab === 'phonepay'" />
```

---

## 4. 폰페이 설정 화면 — `PhonepayView.vue` (신규)

경로: `src/views/partner/settings/PhonepayView.vue`

`AximView.vue` 패턴을 따라 구현. BanqPipe 인증은 API Key 1개만 필요 (Secret 없음).

```vue
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { integrationService } from '@/api/services/integration.service'
import LoadingSkeleton from '@/components/common/LoadingSkeleton.vue'

const loading = ref(false)
const saving = ref(false)
const activating = ref(false)
const isNew = ref(true)

const form = ref({
  apiKey: '',
  isEnabled: false,
  callbackUrl: '',
})

/** 서버에서 받은 원본 활성화 상태 */
const serverEnabled = ref(false)

async function fetchSettings() {
  loading.value = true
  try {
    const settings = await integrationService.getPhonepay()
    if (settings) {
      isNew.value = false
      form.value.apiKey = String(settings.apiKey ?? '')
      form.value.isEnabled = Boolean(settings.isEnabled)
      form.value.callbackUrl = String(settings.callbackUrl ?? '')
      serverEnabled.value = Boolean(settings.isEnabled)
    }
  } catch {
    isNew.value = true
  } finally {
    loading.value = false
  }
}

async function handleSave() {
  saving.value = true
  try {
    const payload: Record<string, unknown> = {}
    if (form.value.apiKey) payload.apiKey = form.value.apiKey
    await integrationService.updatePhonepay(payload)
    isNew.value = false
    await fetchSettings()
  } finally {
    saving.value = false
  }
}

async function handleActivate() {
  activating.value = true
  try {
    await integrationService.activatePhonepay()
    await fetchSettings()
  } finally {
    activating.value = false
  }
}

async function handleDeactivate() {
  activating.value = true
  try {
    await integrationService.deactivatePhonepay()
    await fetchSettings()
  } finally {
    activating.value = false
  }
}

/** Callback URL 복사 */
function copyCallbackUrl() {
  if (form.value.callbackUrl) {
    navigator.clipboard.writeText(form.value.callbackUrl)
  }
}

/** API Key가 존재하는지 (활성화 가능 조건) */
const hasApiKey = computed(() => !!form.value.apiKey)

onMounted(fetchSettings)
</script>

<template>
  <div class="space-y-4">

    <Card class="max-w-xl">
      <CardContent class="pt-6 space-y-6">
        <LoadingSkeleton v-if="loading" :rows="4" />
        <template v-else>

          <!-- 활성화 상태 + 토글 -->
          <div class="flex items-center justify-between">
            <div>
              <label class="text-base font-medium">폰페이 활성화</label>
              <p class="text-sm text-muted-foreground">
                활성화 시 폰페이(BanqPipe) 원화 입금을 수신합니다.
              </p>
            </div>
            <div class="flex items-center gap-2">
              <span class="text-xs" :class="serverEnabled ? 'text-green-600' : 'text-muted-foreground'">
                {{ serverEnabled ? '활성' : '비활성' }}
              </span>
              <Button
                v-if="!serverEnabled"
                size="sm"
                :disabled="activating || !hasApiKey"
                @click="handleActivate"
              >
                {{ activating ? '처리 중...' : '활성화' }}
              </Button>
              <Button
                v-else
                size="sm"
                variant="outline"
                :disabled="activating"
                @click="handleDeactivate"
              >
                {{ activating ? '처리 중...' : '비활성화' }}
              </Button>
            </div>
          </div>

          <hr class="border-border" />

          <!-- API Key 입력 -->
          <div>
            <label class="mb-1 block text-sm font-medium">BanqPipe API Key</label>
            <Input
              v-model="form.apiKey"
              placeholder="BanqPipe에서 발급받은 API Key (bpk_xxx...)"
            />
            <p class="mt-1 text-xs text-muted-foreground">
              BanqPipe 콘솔에서 발급받은 API Key를 입력하세요.
            </p>
          </div>

          <!-- Callback URL (활성화 후 표시) -->
          <div v-if="form.callbackUrl">
            <label class="mb-1 block text-sm font-medium">Callback URL</label>
            <div class="flex items-center gap-2">
              <Input
                :model-value="form.callbackUrl"
                readonly
                class="bg-muted font-mono text-xs"
              />
              <Button size="sm" variant="outline" @click="copyCallbackUrl">
                복사
              </Button>
            </div>
            <p class="mt-1 text-xs text-muted-foreground">
              이 URL을 BanqPipe 콘솔의 Callback URL 설정에 등록하세요.
            </p>
          </div>

          <!-- 저장 버튼 -->
          <Button :disabled="saving" class="w-full" @click="handleSave">
            {{ saving ? '저장 중...' : isNew ? '설정 등록' : '설정 저장' }}
          </Button>

        </template>
      </CardContent>
    </Card>
  </div>
</template>
```

### 4.1 화면 동작 설명

| 상태 | 화면 동작 |
|------|----------|
| 최초 (설정 없음) | API Key 입력 → "설정 등록" 클릭 |
| 설정 저장됨 (비활성) | "활성화" 버튼 표시 (API Key 필수), 클릭 시 Callback URL 자동 생성 |
| 활성화됨 | Callback URL 표시 + 복사 버튼, "비활성화" 버튼 표시 |
| API Key 미입력 | "활성화" 버튼 disabled |

### 4.2 AximView.vue와의 차이

| 항목 | AximView | PhonepayView |
|------|----------|-------------|
| 인증 | apiKey + apiSecret | apiKey만 |
| 활성화 | 토글 스위치 (isEnabled 직접 저장) | 별도 activate/deactivate API 호출 |
| Callback URL | 없음 | 활성화 후 자동 생성, 복사 UI 제공 |
| siteId | 표시 | 없음 |

---

## 5. 폰페이 세션 목록 화면 — `PhonepaySessionsView.vue` (신규)

경로: `src/views/partner/deposits/PhonepaySessionsView.vue`

`AximPaymentsView.vue` 패턴을 따라 구현. 상태 필터 + 검색 + 날짜 필터 + DataTable.

```vue
<script setup lang="ts">
import { onMounted, ref, reactive } from 'vue'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { depositService } from '@/api/services/deposit.service'
import type { XPage } from '@/api/types/common'
import PageHeader from '@/components/common/PageHeader.vue'
import DataTable from '@/components/common/DataTable.vue'
import type { Column } from '@/components/common/DataTable.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import DateDisplay from '@/components/common/DateDisplay.vue'
import SlideDrawer from '@/components/common/SlideDrawer.vue'

const loading = ref(false)
const data = ref<XPage<Record<string, unknown>> | null>(null)
const filters = reactive({
  page: 1,
  size: 20,
  status: undefined as string | undefined,
})

const columns: Column<Record<string, unknown>>[] = [
  { key: 'id', label: 'ID', class: 'w-16 font-mono text-xs' },
  { key: 'banqpipeSessionId', label: 'BanqPipe ID', class: 'font-mono text-xs' },
  { key: 'senderName', label: '송금인' },
  { key: 'recipientName', label: '수취인' },
  { key: 'amount', label: '금액 (원)', class: 'text-right' },
  { key: 'status', label: '상태' },
  { key: 'createdAt', label: '생성일' },
]

async function fetchData() {
  loading.value = true
  try {
    data.value = await depositService.getPhonepaySessions({
      page: filters.page,
      size: filters.size,
      status: filters.status || undefined,
    })
  } finally { loading.value = false }
}

function onSearch() { filters.page = 1; fetchData() }
function onReset() {
  filters.status = undefined
  filters.page = 1
  fetchData()
}
function onPageChange(page: number) { filters.page = page; fetchData() }

// ── 상세 드로어 ──
const drawerOpen = ref(false)
const detail = ref<Record<string, unknown> | null>(null)
const detailLoading = ref(false)

async function openDetail(row: Record<string, unknown>) {
  drawerOpen.value = true
  detailLoading.value = true
  try {
    detail.value = await depositService.getPhonepaySession(row.id as number)
  } finally { detailLoading.value = false }
}

/** 금액 포맷 (천 단위 콤마) */
function formatKrw(amount: unknown): string {
  if (amount == null) return '-'
  return `₩${Number(amount).toLocaleString()}`
}

onMounted(fetchData)
</script>

<template>
  <div class="space-y-4">
    <PageHeader title="폰페이 세션" description="BanqPipe 폰페이 입금 세션 현황" />

    <Card>
      <CardContent class="pt-6 space-y-4">
        <!-- 필터 영역 -->
        <div class="flex flex-wrap items-end gap-3">
          <div>
            <label class="mb-1 block text-xs font-medium text-muted-foreground">상태</label>
            <select v-model="filters.status"
                    class="h-9 w-36 rounded-md border border-input bg-background px-3 text-sm">
              <option :value="undefined">전체</option>
              <option value="WAITING">대기중</option>
              <option value="MATCHED">매칭됨</option>
              <option value="COMPLETED">완료</option>
              <option value="FAILED">실패</option>
              <option value="EXPIRED">만료</option>
              <option value="CANCELLED">취소</option>
            </select>
          </div>
          <div class="flex gap-2">
            <Button size="sm" @click="onSearch">조회</Button>
            <Button size="sm" variant="outline" @click="onReset">초기화</Button>
          </div>
        </div>

        <!-- 데이터 테이블 -->
        <DataTable :columns="columns" :data="data" :loading="loading"
                   @page-change="onPageChange" @row-click="openDetail">
          <template #amount="{ value }">
            <span class="text-sm font-medium">{{ formatKrw(value) }}</span>
          </template>
          <template #status="{ value }">
            <StatusBadge :status="value as string" />
          </template>
          <template #createdAt="{ value }">
            <DateDisplay :date="value as string" />
          </template>
        </DataTable>
      </CardContent>
    </Card>

    <!-- 상세 드로어 -->
    <SlideDrawer v-model="drawerOpen" title="폰페이 세션 상세">
      <div v-if="detailLoading" class="p-4">
        <LoadingSkeleton :rows="6" />
      </div>
      <div v-else-if="detail" class="space-y-4 p-4">
        <div class="grid grid-cols-2 gap-4 text-sm">
          <div>
            <p class="text-muted-foreground">세션 ID</p>
            <p class="font-medium">{{ detail.id }}</p>
          </div>
          <div>
            <p class="text-muted-foreground">상태</p>
            <StatusBadge :status="detail.status as string" />
          </div>
          <div>
            <p class="text-muted-foreground">BanqPipe ID</p>
            <p class="font-mono text-xs">{{ detail.banqpipeSessionId }}</p>
          </div>
          <div>
            <p class="text-muted-foreground">금액</p>
            <p class="font-medium">{{ formatKrw(detail.amount) }}</p>
          </div>
        </div>

        <hr class="border-border" />

        <div class="grid grid-cols-2 gap-4 text-sm">
          <div>
            <p class="text-muted-foreground">송금인</p>
            <p class="font-medium">{{ detail.senderName ?? '-' }}</p>
          </div>
          <div>
            <p class="text-muted-foreground">송금인 전화번호</p>
            <p>{{ detail.senderPhone ?? '-' }}</p>
          </div>
          <div>
            <p class="text-muted-foreground">송금인 은행</p>
            <p>{{ detail.senderBank ?? '-' }}</p>
          </div>
        </div>

        <hr class="border-border" />

        <div class="grid grid-cols-2 gap-4 text-sm">
          <div>
            <p class="text-muted-foreground">수취인</p>
            <p class="font-medium">{{ detail.recipientName ?? '-' }}</p>
          </div>
          <div>
            <p class="text-muted-foreground">수취인 전화번호</p>
            <p>{{ detail.recipientPhone ?? '-' }}</p>
          </div>
          <div>
            <p class="text-muted-foreground">수취인 은행</p>
            <p>{{ detail.recipientBank ?? '-' }}</p>
          </div>
        </div>

        <hr class="border-border" />

        <div class="grid grid-cols-2 gap-4 text-sm">
          <div>
            <p class="text-muted-foreground">입금 ID</p>
            <p>{{ detail.depositId ?? '-' }}</p>
          </div>
          <div>
            <p class="text-muted-foreground">만료 시각</p>
            <DateDisplay :date="detail.expiresAt as string" />
          </div>
          <div>
            <p class="text-muted-foreground">완료 시각</p>
            <DateDisplay :date="detail.completedAt as string" />
          </div>
          <div>
            <p class="text-muted-foreground">생성 시각</p>
            <DateDisplay :date="detail.createdAt as string" />
          </div>
        </div>
      </div>
    </SlideDrawer>
  </div>
</template>
```

### 5.1 테이블 컬럼 설명

| 컬럼 | 데이터 | 비고 |
|------|--------|------|
| ID | `id` | DB PK |
| BanqPipe ID | `banqpipeSessionId` | `dp_xxx...` 형식 |
| 송금인 | `senderName` | eKYC 인증된 이름 |
| 수취인 | `recipientName` | BanqPipe 배정 |
| 금액 (원) | `amount` | KRW, 천 단위 콤마 |
| 상태 | `status` | StatusBadge 컴포넌트 |
| 생성일 | `createdAt` | DateDisplay 컴포넌트 |

### 5.2 상세 드로어

행 클릭 시 `SlideDrawer`로 상세 정보 표시. `getPhonepaySession(id)` API 호출.
송금인/수취인 정보, 입금 연결(depositId), 시간 정보(만료/완료/생성) 포함.

---

## 6. 라우터 추가 — `router/index.ts` 수정

경로: `src/router/index.ts`

### 6.1 children 배열에 폰페이 세션 라우트 추가

`axim-payments` 라우트 바로 아래에 추가:

```typescript
      { path: 'phonepay-sessions', name: 'partner-phonepay-sessions', component: () => import('@/views/partner/deposits/PhonepaySessionsView.vue'), meta: { title: '폰페이 세션' } },
```

### 6.2 설정 > 폰페이 리다이렉트 추가 (선택)

```typescript
      { path: 'settings/phonepay', redirect: { name: 'partner-settings-integration', query: { tab: 'phonepay' } } },
```

기존 `settings/axim` 리다이렉트 아래에 추가.

---

## 7. 사이드바 메뉴 추가 — `utils/constants.ts` 수정

경로: `src/utils/constants.ts`

### 7.1 입금 관리 메뉴에 폰페이 추가

```typescript
{
  label: '입금 관리', icon: ArrowDownToLine,
  children: [
    { label: '입금 내역', path: '/partner/deposits' },
    { label: '입금 예약', path: '/partner/deposit-reservations' },
    { label: '결제 링크', path: '/partner/payment-links' },
    { label: 'Axim Pay', path: '/partner/axim-payments' },
    { label: '폰페이', path: '/partner/phonepay-sessions' },   // ← 추가
  ],
},
```

### 7.2 상태 색상/라벨 확인

`STATUS_COLORS`와 `STATUS_LABELS_KO`에 이미 폰페이 상태값이 포함되어 있는지 확인:

| 상태 | STATUS_COLORS | STATUS_LABELS_KO |
|------|--------------|-----------------|
| WAITING | ✅ (없지만 매칭 안 됨 → 기본 스타일) | ✅ `대기중` |
| MATCHED | ✅ (없음) | ✅ `매칭됨` |
| COMPLETED | ✅ `bg-green-100` | ✅ `완료` |
| FAILED | ✅ `bg-red-100` | ✅ `실패` |
| EXPIRED | ✅ `bg-gray-100` | ✅ `만료` |
| CANCELLED | ✅ `bg-gray-100` | ✅ `취소` |

**WAITING**과 **MATCHED**가 `STATUS_COLORS`에 없다면 추가:

```typescript
// STATUS_COLORS에 추가 (없는 경우만)
WAITING: 'bg-yellow-100 text-yellow-800',
MATCHED: 'bg-blue-100 text-blue-800',
```

> 확인: `constants.ts`를 보면 `MATCHED: '매칭됨'`, `WAITING: '대기중'`은 `STATUS_LABELS_KO`에 이미 있음.
> `STATUS_COLORS`에도 추가가 필요할 수 있음 — 기존에 없으면 `StatusBadge`가 기본 회색으로 표시됨. 필요 시 추가.

---

## 8. 체크리스트

| # | 항목 | 파일 | 유형 |
|---|------|------|------|
| 1 | `integration.service.ts`에 폰페이 설정 API 4개 추가 | api/services/ | 수정 |
| 2 | `deposit.service.ts`에 폰페이 세션 API 2개 추가 | api/services/ | 수정 |
| 3 | `IntegrationSettingsView.vue`에 폰페이 탭 추가 | views/partner/settings/ | 수정 |
| 4 | `PhonepayView.vue` 생성 (설정 화면) | views/partner/settings/ | 신규 |
| 5 | `PhonepaySessionsView.vue` 생성 (세션 목록) | views/partner/deposits/ | 신규 |
| 6 | `router/index.ts`에 폰페이 세션 라우트 추가 | router/ | 수정 |
| 7 | `constants.ts` 사이드바 메뉴에 폰페이 추가 | utils/ | 수정 |
| 8 | `STATUS_COLORS`에 WAITING/MATCHED 색상 추가 (필요 시) | utils/ | 수정 |
| 9 | 빌드 확인: `npm run build` (또는 `npm run dev`로 화면 확인) | — | — |

---

## 9. 화면 플로우

### 9.1 폰페이 설정 (연동 설정 > 폰페이 탭)

```
파트너 콘솔                                       partner-api
    │                                               │
    │ ① 연동 설정 → 폰페이 탭 클릭                    │
    │   GET /api/partner/integration/phonepay       │
    │ ──────────────────────────────────────────>  │
    │  { apiKey: "bpk_...", isEnabled: false }       │
    │ <──────────────────────────────────────────  │
    │                                               │
    │ ② API Key 입력 → "설정 저장" 클릭               │
    │   PUT /api/partner/integration/phonepay       │
    │   { apiKey: "bpk_new..." }                    │
    │ ──────────────────────────────────────────>  │
    │                                               │
    │ ③ "활성화" 클릭                                 │
    │   POST /api/partner/integration/phonepay/     │
    │        activate                               │
    │ ──────────────────────────────────────────>  │
    │  { isEnabled: true,                           │
    │    callbackUrl: "https://api.cryptoments.cc/  │
    │      webhooks/banqpipe/{partnerId}" }          │
    │ <──────────────────────────────────────────  │
    │                                               │
    │  화면: Callback URL 표시 + 복사 버튼            │
    │  안내: "이 URL을 BanqPipe 콘솔에 등록하세요"     │
```

### 9.2 폰페이 세션 목록 (입금 관리 > 폰페이)

```
파트너 콘솔                                       partner-api
    │                                               │
    │ ① 사이드바 "폰페이" 클릭                        │
    │   GET /api/partner/phonepay/sessions          │
    │       ?page=1&size=20                         │
    │ ──────────────────────────────────────────>  │
    │  { list: [...], total: 42 }                   │
    │ <──────────────────────────────────────────  │
    │                                               │
    │ ② 상태 필터 "완료" 선택 → "조회" 클릭            │
    │   GET /api/partner/phonepay/sessions          │
    │       ?page=1&size=20&status=COMPLETED        │
    │ ──────────────────────────────────────────>  │
    │                                               │
    │ ③ 행 클릭 → 상세 드로어                         │
    │   GET /api/partner/phonepay/sessions/{id}     │
    │ ──────────────────────────────────────────>  │
    │  { id: 1, senderName: "홍길동", ... }          │
    │ <──────────────────────────────────────────  │
```

---

## 10. 주의사항

### 10.1 AximView vs PhonepayView 활성화 패턴 차이

`AximView.vue`는 `isEnabled`를 토글 스위치로 직접 수정 후 저장하는 패턴이지만,
`PhonepayView.vue`는 **별도 activate/deactivate API**를 호출하는 패턴이다.

이유: 폰페이 활성화 시 Callback URL을 자동 생성해야 하므로, 단순 필드 업데이트가 아닌 서버 로직이 필요하다.

### 10.2 LoadingSkeleton import

`PhonepaySessionsView.vue`의 상세 드로어에서 `LoadingSkeleton`을 사용한다.
import를 확인:

```typescript
import LoadingSkeleton from '@/components/common/LoadingSkeleton.vue'
```

### 10.3 SlideDrawer 컴포넌트

`SlideDrawer`는 기존 프로젝트에 이미 있음 (`components/common/SlideDrawer.vue`).
v-model로 열기/닫기를 제어한다.

### 10.4 DataTable row-click 이벤트

기존 `DataTable` 컴포넌트가 `@row-click` 이벤트를 지원하는지 확인 필요.
지원하지 않으면 ID 컬럼을 클릭 가능한 링크로 변경하거나, DataTable에 emit을 추가해야 한다.

**대안** (DataTable이 row-click 미지원 시):
```html
<template #id="{ row }">
  <button class="text-primary hover:underline font-mono text-xs" @click="openDetail(row)">
    {{ row.id }}
  </button>
</template>
```
