# Guide #69 — Partner UI 미적용 항목 통합 지침서

**작성일**: 2026-03-26
**대상**: partner-ui (Vue 3)
**범위**: Guide #65, #66, #68 미적용 항목 통합

---

## 현황 요약

| 영역 | 상태 | 이 지침서 |
|------|------|----------|
| Guide #67 프론트 (KRW 컬럼, 네트워크 컬럼, formatAmountKrw) | ✅ 완료 | — |
| Guide #67 백엔드 (PriceService USDT 수정) | ✅ 완료 | — |
| Guide #68 컴포넌트 (CryptoAmountInput.vue, useExchangeRate.ts) | ✅ 생성됨 | — |
| Guide #68 뷰 적용 (5개 뷰, 6개 입력 필드) | ☐ 미적용 | **Part C** |
| Guide #65 OTP (api 헬퍼 + 8개 뷰 + 로그인 이력) | ☐ 미적용 | **Part A + B + D** |
| Guide #66 FAILED 출금 취소 | ☐ 미적용 | **Part E** |

---

## Part A — api 헬퍼 headers 지원 추가

### A-1. 문제

현재 `api.put/post/delete`에 커스텀 헤더를 전달할 수 없어서 `X-OTP-Code` 헤더를 넣을 수 없음.

### A-2. 변경

**파일**: `src/api/client.ts`

**① api 객체 수정** — `post`, `put`, `patch`, `delete`에 `headers` 옵션 추가:

```ts
export const api = {
  get: <T>(url: string, params?: RequestParams) =>
    apiClient.get<T>(url, { params: buildParams(params) }).then((r) => r.data),

  post: <T>(url: string, data?: unknown, opts?: { params?: RequestParams; headers?: Record<string, string> }) =>
    apiClient.post<T>(url, data, {
      params: buildParams(opts?.params),
      headers: opts?.headers,
    }).then((r) => r.data),

  put: <T>(url: string, data?: unknown, opts?: { params?: RequestParams; headers?: Record<string, string> }) =>
    apiClient.put<T>(url, data, {
      params: buildParams(opts?.params),
      headers: opts?.headers,
    }).then((r) => r.data),

  patch: <T>(url: string, data?: unknown, opts?: { params?: RequestParams; headers?: Record<string, string> }) =>
    apiClient.patch<T>(url, data, {
      params: buildParams(opts?.params),
      headers: opts?.headers,
    }).then((r) => r.data),

  delete: <T>(url: string, opts?: { headers?: Record<string, string> }) =>
    apiClient.delete<T>(url, {
      headers: opts?.headers,
    }).then((r) => r.data),
}
```

**② otpHeaders 유틸 추가** — 같은 파일 하단:

```ts
/** OTP 코드 → X-OTP-Code 헤더 객체. 없으면 undefined. */
export function otpHeaders(otpCode?: string): Record<string, string> | undefined {
  return otpCode ? { 'X-OTP-Code': otpCode } : undefined
}
```

**기존 호출에 영향 없음**: `opts`가 optional이므로 기존 `api.put(url, data)` 호출은 그대로 동작.

---

## Part B — OTP 연동 (8개 뷰)

### 공통 패턴

모든 대상 뷰에 추가:

```ts
// script setup 상단
import { useOtpVerification } from '@/composables/useOtpVerification'
import OtpVerificationModal from '@/components/common/OtpVerificationModal.vue'
import { api, otpHeaders } from '@/api/client'

const { showOtpModal, otpError, withOtp, onOtpSubmit, onOtpCancel } = useOtpVerification()
```

```html
<!-- template 최하단 (root element 닫기 직전) -->
<OtpVerificationModal
  :open="showOtpModal"
  :error="otpError"
  @submit="onOtpSubmit"
  @cancel="onOtpCancel"
/>
```

### withOtp 흐름

```
save() → withOtp(apiCall)
  → 첫 시도: apiCall(undefined) → 헤더 없이 요청
  ├─ 2FA OFF → 서버 통과 → 성공
  └─ 2FA ON  → 서버 OTP_REQUIRED
       → OtpVerificationModal 자동 표시
       → 6자리 입력 → apiCall(otpCode) 재시도
       → X-OTP-Code 헤더 포함 → 성공/실패
```

### OTP 취소 에러 무시

`withOtp()` 취소 시 `'OTP 인증 취소'` 에러가 throw됨. 모든 catch에서 무시 처리:

```ts
catch (e) {
  if ((e as Error).message !== 'OTP 인증 취소') {
    error.value = (e as Error).message
  }
}
```

---

### B-1. FeeRateView.vue

**파일**: `src/views/partner/settings/FeeRateView.vue`

**현재** `save()`:

```ts
async function save() {
  error.value = ''; message.value = ''; saving.value = true
  try {
    await api.put('/api/partner/settings/fee-rate', {
      depositFeeRate: Number(depositFeeRate.value) / 100,
    })
    message.value = '수수료율이 저장되었습니다.'
  } catch (e) { error.value = (e as Error).message } finally { saving.value = false }
}
```

**변경**:

```ts
async function save() {
  error.value = ''; message.value = ''; saving.value = true
  try {
    await withOtp((otpCode?: string) =>
      api.put('/api/partner/settings/fee-rate', {
        depositFeeRate: Number(depositFeeRate.value) / 100,
      }, { headers: otpHeaders(otpCode) })
    )
    message.value = '수수료율이 저장되었습니다.'
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      error.value = (e as Error).message
    }
  } finally { saving.value = false }
}
```

---

### B-2. ExchangeRateView.vue

**파일**: `src/views/partner/settings/ExchangeRateView.vue`

**추가 필요**: `const error = ref('')` (현재 없음)

**현재** `save()`:

```ts
async function save() {
  saving.value = true; message.value = ''
  try {
    await api.put('/api/partner/settings/exchange-rate', {
      rateType: rateType.value,
      fixedRate: rateType.value === 'FIXED' ? Number(fixedRate.value) : null,
    })
    message.value = '환율 정책이 저장되었습니다.'
  } finally { saving.value = false }
}
```

**변경**:

```ts
async function save() {
  saving.value = true; message.value = ''; error.value = ''
  try {
    await withOtp((otpCode?: string) =>
      api.put('/api/partner/settings/exchange-rate', {
        rateType: rateType.value,
        fixedRate: rateType.value === 'FIXED' ? Number(fixedRate.value) : null,
      }, { headers: otpHeaders(otpCode) })
    )
    message.value = '환율 정책이 저장되었습니다.'
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      error.value = (e as Error).message
    }
  } finally { saving.value = false }
}
```

**template 추가** — `message` 표시 위에:

```html
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
```

---

### B-3. WithdrawalListView.vue — 출금 요청 모달

**파일**: `src/views/partner/withdrawals/WithdrawalListView.vue`

이 뷰는 3가지 OTP 대상을 포함:
- 출금 요청 (`onWithdrawSubmit`)
- 출금 승인 (`handleApprove`)
- 출금 취소는 OTP 대상 아님

**현재** `onWithdrawSubmit()`:

```ts
async function onWithdrawSubmit() {
  // ... validation 생략 ...
  withdrawSaving.value = true
  try {
    await withdrawalService.createWithdrawal({
      currencyId: withdrawNC.value.currencyId,
      networkId: withdrawNC.value.networkId,
      toAddress: addr,
      whitelistId: ...,
      amount: Number(withdrawAmount.value),
    })
    withdrawSuccess.value = true
    fetchData()
  } catch (e) { withdrawError.value = (e as Error).message } finally { withdrawSaving.value = false }
}
```

**변경**:

```ts
async function onWithdrawSubmit() {
  // ... validation 동일 ...
  withdrawError.value = ''
  withdrawSaving.value = true
  try {
    await withOtp((otpCode?: string) =>
      api.post('/api/partner/withdrawals', {
        currencyId: withdrawNC.value.currencyId,
        networkId: withdrawNC.value.networkId,
        toAddress: addr,
        whitelistId: withdrawAddressMode.value === 'whitelist' ? withdrawSelectedWlId.value : undefined,
        amount: Number(withdrawAmount.value),
      }, { headers: otpHeaders(otpCode) })
    )
    withdrawSuccess.value = true
    fetchData()
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      withdrawError.value = (e as Error).message
    }
  } finally { withdrawSaving.value = false }
}
```

**현재** `handleApprove()`:

```ts
async function handleApprove(id: number) {
  try { await withdrawalService.approveWithdrawal(id); drawerOpen.value = false; fetchData() } catch { /**/ }
}
```

**변경**:

```ts
async function handleApprove(id: number) {
  try {
    await withOtp((otpCode?: string) =>
      api.post(`/api/partner/withdrawals/${id}/approve`, null, { headers: otpHeaders(otpCode) })
    )
    drawerOpen.value = false
    fetchData()
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      // 에러 표시 (선택: 토스트 또는 alert)
    }
  }
}
```

> `handleReject`, `handleCancel`은 현재 `@RequiresOtp` 대상이 아니므로 변경 불필요.

---

### B-4. WithdrawalNewView.vue — 별도 출금 요청 페이지

**파일**: `src/views/partner/withdrawals/WithdrawalNewView.vue`

> 이 뷰는 WithdrawalListView의 모달과 별도로 존재하는 독립 페이지.

**현재** `onSubmit()`:

```ts
async function onSubmit() {
  // ... validation ...
  saving.value = true
  try {
    await withdrawalService.createWithdrawal({ ... })
    success.value = true
  } catch (e) { error.value = (e as Error).message } finally { saving.value = false }
}
```

**변경**:

```ts
async function onSubmit() {
  // ... validation 동일 ...
  saving.value = true
  try {
    await withOtp((otpCode?: string) =>
      api.post('/api/partner/withdrawals', {
        currencyId: networkCurrency.value.currencyId,
        networkId: networkCurrency.value.networkId,
        toAddress: addr,
        whitelistId: addressMode.value === 'whitelist' ? selectedWhitelistId.value : undefined,
        amount: Number(amount.value),
        partnerUserId: partnerUserId.value || undefined,
      }, { headers: otpHeaders(otpCode) })
    )
    success.value = true
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      error.value = (e as Error).message
    }
  } finally { saving.value = false }
}
```

---

### B-5. WithdrawalPolicyView.vue

**파일**: `src/views/partner/withdrawals/WithdrawalPolicyView.vue`

> ⚠️ 현재 `PUT /api/partner/withdrawals/policy`에는 `@RequiresOtp` 없음.
> 서버에 추가 전까지 OTP 모달이 뜨지 않고 바로 통과됨 (안전한 선적용).

**현재** `save()` — `withdrawalService.updateWithdrawalPolicy()` 사용

**변경**:

```ts
async function save() {
  error.value = ''; message.value = ''; saving.value = true
  try {
    await withOtp((otpCode?: string) =>
      api.put('/api/partner/withdrawals/policy', {
        singleLimit: form.value.singleLimit ? Number(form.value.singleLimit) : null,
        dailyLimit: form.value.dailyLimit ? Number(form.value.dailyLimit) : null,
        autoApproveThreshold: form.value.autoApproveThreshold ? Number(form.value.autoApproveThreshold) : null,
        addressWhitelistEnabled: form.value.addressWhitelistEnabled,
      }, { headers: otpHeaders(otpCode) })
    )
    message.value = '출금 정책이 저장되었습니다.'
    fetchPolicy()
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      error.value = (e as Error).message
    }
  } finally { saving.value = false }
}
```

---

### B-6. MyAccountView.vue (비밀번호 변경)

**파일**: `src/views/partner/account/MyAccountView.vue`

**현재** `changePassword()` — `accountService.changePassword()` 사용

**변경**:

```ts
async function changePassword() {
  pwError.value = ''; pwMessage.value = ''
  if (newPassword.value !== newPasswordConfirm.value) {
    pwError.value = '새 비밀번호가 일치하지 않습니다.'; return
  }
  pwSaving.value = true
  try {
    const resp = await withOtp((otpCode?: string) =>
      api.post<{ message?: string }>('/api/partner/account/password', {
        currentPassword: currentPassword.value,
        newPassword: newPassword.value,
        newPasswordConfirm: newPasswordConfirm.value,
      }, { headers: otpHeaders(otpCode) })
    )
    pwMessage.value = resp?.message ?? '비밀번호가 변경되었습니다.'
    currentPassword.value = ''; newPassword.value = ''; newPasswordConfirm.value = ''
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      pwError.value = (e as Error).message
    }
  } finally { pwSaving.value = false }
}
```

---

### B-7. ApiKeyView.vue (API 키 재발급)

**파일**: `src/views/partner/settings/ApiKeyView.vue`

**추가 필요**: `const error = ref('')`

**현재** `regenerate()` — `integrationService.regenerateApiKey()` 사용

**변경**:

```ts
const error = ref('')

async function regenerate() {
  regenerating.value = true; error.value = ''
  try {
    const resp = await withOtp((otpCode?: string) =>
      api.post<ApiKeyResponse>('/api/partner/integrations/api-key/regenerate', null, {
        headers: otpHeaders(otpCode),
      })
    )
    apiKey.value = resp
    newApiKey.value = resp.apiKey ?? null
    showConfirm.value = false
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      error.value = (e as Error).message
    }
  } finally { regenerating.value = false }
}
```

**template 추가**: 재발급 확인 영역에

```html
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
```

---

### B-8. SubPartnerNewView.vue (하위 파트너 등록)

**파일**: `src/views/partner/subpartners/SubPartnerNewView.vue`

**현재** `onSubmit()` — `subPartnerService.createSubPartner()` 사용

**변경**: `onSubmit()` 내부의 service 호출을 `withOtp` + `api.post` 로 교체:

```ts
async function onSubmit() {
  error.value = ''; saving.value = true
  try {
    const resp = await withOtp((otpCode?: string) =>
      api.post<SubPartnerCreateResponse>('/api/partner/subpartners', {
        partnerType: form.value.partnerType,
        partnerName: form.value.partnerName,
        loginEmail: form.value.loginEmail,
        password: form.value.password,
        feeSettings: form.value.feeSettings,
      }, { headers: otpHeaders(otpCode) })
    )
    // resp에서 partnerCode, apiKey, apiSecret 표시
    result.value = resp
    success.value = true
  } catch (e) {
    if ((e as Error).message !== 'OTP 인증 취소') {
      error.value = (e as Error).message
    }
  } finally { saving.value = false }
}
```

> SubPartner 삭제 (`DELETE /api/partner/subpartners/{id}`)는 현재 UI에 삭제 버튼이 없음.
> SubPartnerDetailView는 읽기 전용. 삭제 UI 추가 시점에 OTP 래핑 적용.

---

## Part C — CryptoAmountInput 뷰 적용

### 현황

| 뷰 | CryptoAmountInput | 상태 |
|----|-------------------|------|
| WithdrawalListView (출금 모달) | ✅ 적용됨 | — |
| SettlementView (출금 모달) | ✅ 적용됨 | — |
| DepositSessionNewView | ☐ `<Input type="number">` | **C-1** |
| PaymentLinksView | ☐ `<Input type="number">` | **C-2** |
| WithdrawView (정산 출금) | ☐ `<Input type="number">` | **C-3** |
| WithdrawalNewView (별도 페이지) | ☐ `<Input type="number">` | **C-4** |
| UserDetailView (Axim 모달) | ☐ `<Input type="number">` | **C-5** |
| UserDetailView (결제 링크 모달) | ☐ `<Input type="number">` | **C-6** |

### 공통 import

```ts
import CryptoAmountInput from '@/components/common/CryptoAmountInput.vue'
import { useExchangeRate } from '@/composables/useExchangeRate'

const { exchangeRate } = useExchangeRate()
```

---

### C-1. DepositSessionNewView.vue

**파일**: `src/views/partner/deposits/DepositSessionNewView.vue`
**변수**: `amount` (line 20)

**현재** (line 87):

```html
<Input v-model="amount" type="number" placeholder="100" />
```

**변경**:

```html
<CryptoAmountInput
  v-model="amount"
  label="요청 금액"
  :exchange-rate="exchangeRate"
  placeholder="미입력 시 고객이 입력"
/>
```

---

### C-2. PaymentLinksView.vue

**파일**: `src/views/partner/deposits/PaymentLinksView.vue`
**변수**: `form.amount` (line 56)

**현재** (line 112):

```html
<Input v-model="form.amount" type="number" placeholder="미입력 시 고객이 입력" />
```

**변경**:

```html
<CryptoAmountInput
  v-model="form.amount"
  label="결제 금액 (선택)"
  :exchange-rate="exchangeRate"
  placeholder="미입력 시 고객이 입력"
/>
```

---

### C-3. WithdrawView.vue (정산 출금)

**파일**: `src/views/partner/settlement/WithdrawView.vue`
**변수**: `amount` (line 22)

**현재** (line 93):

```html
<Input v-model="amount" type="number" placeholder="0" />
```

**변경**:

```html
<CryptoAmountInput
  v-model="amount"
  label="출금 금액"
  :required="true"
  :exchange-rate="exchangeRate"
/>
```

> MAX 버튼이 있으면 CryptoAmountInput 아래에 배치:
>
> ```html
> <div class="flex justify-end">
>   <Button variant="link" size="sm" class="text-xs" @click="amount = String(maxBalance)">MAX</Button>
> </div>
> ```

---

### C-4. WithdrawalNewView.vue (별도 페이지)

**파일**: `src/views/partner/withdrawals/WithdrawalNewView.vue`
**변수**: `amount` (line 26)

**현재** (line 94):

```html
<div><label class="mb-1 block text-sm font-medium">출금 금액 *</label><Input v-model="amount" type="number" placeholder="0" /></div>
```

**변경**:

```html
<CryptoAmountInput
  v-model="amount"
  label="출금 금액"
  :required="true"
  :exchange-rate="exchangeRate"
  placeholder="출금할 수량"
/>
```

---

### C-5. UserDetailView.vue — Axim 결제 모달

**파일**: `src/views/partner/users/UserDetailView.vue`
**변수**: `aximForm.amount` (line 86)

**현재** (line 243):

```html
<Input v-model="aximForm.amount" type="number" placeholder="0.00" />
```

**변경**:

```html
<CryptoAmountInput
  v-model="aximForm.amount"
  label="결제 금액"
  :required="true"
  :exchange-rate="exchangeRate"
/>
```

---

### C-6. UserDetailView.vue — 결제 링크 모달

**변수**: `plForm.amount` (line 92)

**현재** (line 268):

```html
<Input v-model="plForm.amount" type="number" placeholder="0.00" />
```

**변경**:

```html
<CryptoAmountInput
  v-model="plForm.amount"
  label="결제 금액 (선택)"
  :exchange-rate="exchangeRate"
  placeholder="미입력 시 고객이 입력"
/>
```

> UserDetailView는 import 1회 + useExchangeRate 1회로 두 모달 모두 처리.

---

## Part D — 로그인 이력 탭 제거

### D-1. MyAccountView.vue

**파일**: `src/views/partner/account/MyAccountView.vue`

**① 타입 변경:**

```diff
- const activeTab = ref<'profile' | 'password' | '2fa' | 'history'>('profile')
+ const activeTab = ref<'profile' | 'password' | '2fa'>('profile')
```

**② tabs 배열:**

```diff
  const tabs = [
    { key: 'profile' as const, label: '기본 정보' },
    { key: 'password' as const, label: '비밀번호 변경' },
    { key: '2fa' as const, label: '2FA 설정' },
-   { key: 'history' as const, label: '로그인 이력' },
  ]
```

**③ template — history 탭 블록 삭제:**

```diff
- <Card v-if="activeTab === 'history'">
-   <CardContent class="pt-6">
-     <div class="rounded border border-amber-500/50 bg-amber-500/10 p-4">
-       <p class="text-sm text-amber-700">로그인 이력 API가 아직 제공되지 않습니다.</p>
-     </div>
-   </CardContent>
- </Card>
```

### D-2. LoginHistoryView.vue 삭제

**파일**: `src/views/partner/account/LoginHistoryView.vue` — 삭제

### D-3. router

로그인 이력 관련 라우트가 있으면 제거.

---

## Part E — FAILED 출금 취소 허용

### E-1. 백엔드 (Spring, 1줄 수정)

**파일**: `core/.../withdrawal/WithdrawalService.java` — `cancelByPartner()` 메서드

**현재**: REQUESTED, PENDING_APPROVAL만 허용

**변경**: FAILED 추가

```java
// 기존
if (status != WithdrawalStatus.REQUESTED && status != WithdrawalStatus.PENDING_APPROVAL) {
    throw new BadRequestException(...);
}

// 변경
if (status != WithdrawalStatus.REQUESTED
    && status != WithdrawalStatus.PENDING_APPROVAL
    && status != WithdrawalStatus.FAILED) {
    throw new BadRequestException(...);
}
```

### E-2. 프론트엔드 — WithdrawalListView.vue

**파일**: `src/views/partner/withdrawals/WithdrawalListView.vue`

**현재** (line 208-210) — REQUESTED만 취소 버튼:

```html
<div v-else-if="selected.status === 'REQUESTED'" class="flex gap-2 mt-4 pt-4 border-t">
  <Button size="sm" variant="outline" @click="handleCancel(selected.id)">취소</Button>
</div>
```

**변경** — FAILED도 취소 가능:

```html
<div v-else-if="selected.status === 'REQUESTED' || selected.status === 'FAILED'" class="flex gap-2 mt-4 pt-4 border-t">
  <Button size="sm" variant="outline" @click="handleCancel(selected.id)">취소</Button>
</div>
```

---

## 체크리스트

### Part A — api 헬퍼

| # | 항목 | 파일 |
|---|------|------|
| A-1 | `api.post/put/patch/delete`에 `{ headers }` 옵션 추가 | `api/client.ts` |
| A-2 | `otpHeaders()` 유틸 함수 추가 | `api/client.ts` |

### Part B — OTP 연동

| # | 항목 | 파일 |
|---|------|------|
| B-1 | FeeRateView `save()` withOtp 래핑 + 모달 | `settings/FeeRateView.vue` |
| B-2 | ExchangeRateView `save()` withOtp 래핑 + error ref + 모달 | `settings/ExchangeRateView.vue` |
| B-3 | WithdrawalListView `onWithdrawSubmit()` + `handleApprove()` withOtp 래핑 + 모달 | `withdrawals/WithdrawalListView.vue` |
| B-4 | WithdrawalNewView `onSubmit()` withOtp 래핑 + 모달 | `withdrawals/WithdrawalNewView.vue` |
| B-5 | WithdrawalPolicyView `save()` withOtp 래핑 + 모달 | `withdrawals/WithdrawalPolicyView.vue` |
| B-6 | MyAccountView `changePassword()` withOtp 래핑 + 모달 | `account/MyAccountView.vue` |
| B-7 | ApiKeyView `regenerate()` withOtp 래핑 + error ref + 모달 | `settings/ApiKeyView.vue` |
| B-8 | SubPartnerNewView `onSubmit()` withOtp 래핑 + 모달 | `subpartners/SubPartnerNewView.vue` |

### Part C — CryptoAmountInput 적용

| # | 항목 | 파일 |
|---|------|------|
| C-1 | DepositSessionNewView — `amount` 교체 | `deposits/DepositSessionNewView.vue` |
| C-2 | PaymentLinksView — `form.amount` 교체 | `deposits/PaymentLinksView.vue` |
| C-3 | WithdrawView — `amount` 교체 | `settlement/WithdrawView.vue` |
| C-4 | WithdrawalNewView — `amount` 교체 | `withdrawals/WithdrawalNewView.vue` |
| C-5 | UserDetailView — `aximForm.amount` 교체 | `users/UserDetailView.vue` |
| C-6 | UserDetailView — `plForm.amount` 교체 | `users/UserDetailView.vue` |

### Part D — 로그인 이력 제거

| # | 항목 | 파일 |
|---|------|------|
| D-1 | tabs에서 history 제거 + template 삭제 | `account/MyAccountView.vue` |
| D-2 | LoginHistoryView.vue 삭제 | `account/LoginHistoryView.vue` |
| D-3 | router 정리 (있으면) | `router/index.ts` |

### Part E — FAILED 출금 취소

| # | 항목 | 파일 |
|---|------|------|
| E-1 | `cancelByPartner()` FAILED 허용 | `core/.../WithdrawalService.java` |
| E-2 | WithdrawalListView FAILED 취소 버튼 | `withdrawals/WithdrawalListView.vue` |

### 테스트

| # | 항목 |
|---|------|
| T-1 | 2FA ON: 수수료 저장 → OTP 모달 → 코드 입력 → 저장 성공 |
| T-2 | 2FA OFF: 수수료 저장 → OTP 모달 안 뜸 → 바로 성공 |
| T-3 | OTP 모달 취소 → 에러 메시지 미표시 확인 |
| T-4 | 잘못된 OTP → 모달 내 에러 → 재입력 가능 |
| T-5 | CryptoAmountInput USDT 입력 → KRW 환산 표시 |
| T-6 | CryptoAmountInput KRW 입력 → USDT 변환 표시 |
| T-7 | FAILED 출금 → 취소 버튼 → CANCELLED 전환 |
| T-8 | 타입 체크 + 빌드 통과 |

---

## Part F — DepositSessionNewView "금액 단위" 셀렉트 제거 + inputMode 연동

### 문제

현재 DepositSessionNewView에 **CryptoAmountInput**(USDT/KRW 토글)과 **"금액 단위" 셀렉트**(토큰 기준/KRW 환산)가 동시에 존재.
역할 중복이고 사용자 혼란을 일으킴.

### 해결 — 2단계

#### F-1. CryptoAmountInput에 inputMode 외부 노출 (v-model:mode)

**파일**: `src/components/common/CryptoAmountInput.vue`

**① emits에 mode 변경 이벤트 추가:**

```diff
  const emit = defineEmits<{
    'update:modelValue': [value: string]
+   'update:mode': [value: 'crypto' | 'krw']
  }>()
```

**② props에 mode 추가 (선택적 v-model):**

```diff
  const props = withDefaults(defineProps<{
    modelValue: string | number
+   mode?: 'crypto' | 'krw'
    currencySymbol?: string
    exchangeRate?: number | null
    required?: boolean
    label?: string
    placeholder?: string
    disabled?: boolean
  }>(), {
+   mode: 'crypto',
    currencySymbol: 'USDT',
    required: false,
    label: '금액',
    placeholder: '',
    disabled: false,
  })
```

**③ inputMode를 props.mode와 동기화:**

```diff
- const inputMode = ref<'crypto' | 'krw'>('crypto')
+ const inputMode = ref<'crypto' | 'krw'>(props.mode)
+
+ // 외부에서 mode가 변경되면 동기화
+ watch(() => props.mode, (val) => { if (val !== inputMode.value) switchMode(val) })
```

**④ switchMode에서 emit 추가:**

```diff
  function switchMode(mode: 'crypto' | 'krw') {
    if (mode === inputMode.value) return
    inputMode.value = mode
+   emit('update:mode', mode)
    if (mode === 'krw' && cryptoValue.value) {
      const krw = cryptoToKrw(Number(cryptoValue.value))
      krwInput.value = krw > 0 ? String(krw) : ''
    }
  }
```

> `import { ref, computed, watch } from 'vue'` — watch 추가 필요.

**기존 사용처에 영향 없음**: `mode` prop이 optional이므로 기존 `<CryptoAmountInput v-model="amount" .../>` 호출은 그대로 동작.

---

#### F-2. DepositSessionNewView — 금액 단위 셀렉트 제거 + mode 연동

**파일**: `src/views/partner/deposits/DepositSessionNewView.vue`

**① amountCurrency를 inputMode에서 자동 결정:**

```diff
- const amountCurrency = ref('')
+ const amountInputMode = ref<'crypto' | 'krw'>('crypto')
+
+ // inputMode → amountCurrency 자동 매핑
+ const amountCurrency = computed(() => amountInputMode.value === 'krw' ? 'KRW' : '')
```

> `import { ref, computed } from 'vue'` — computed 추가 필요.

**② template — "금액 단위" 셀렉트 제거 + CryptoAmountInput에 v-model:mode 바인딩:**

```diff
- <div class="grid gap-4 sm:grid-cols-2">
-   <div><CryptoAmountInput v-model="amount" label="요청 금액" :exchange-rate="exchangeRate" placeholder="미입력 시 고객이 입력" /></div>
-   <div>
-     <label class="mb-1 block text-sm font-medium">금액 단위</label>
-     <select v-model="amountCurrency" class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm">
-       <option value="">토큰 기준</option>
-       <option value="KRW">KRW 환산</option>
-     </select>
-   </div>
- </div>
+ <CryptoAmountInput
+   v-model="amount"
+   v-model:mode="amountInputMode"
+   label="요청 금액"
+   :exchange-rate="exchangeRate"
+   placeholder="미입력 시 고객이 입력"
+ />
```

**③ onSubmit() — amountCurrency는 computed이므로 변경 불필요:**

```ts
// 기존 코드 그대로 동작
amountCurrency: amountCurrency.value || undefined,
```

USDT 탭으로 입력 → `amountCurrency = ''` (토큰 기준)
KRW 탭으로 입력 → `amountCurrency = 'KRW'` (원화 환산)

---

### Part F 체크리스트

| # | 항목 | 파일 |
|---|------|------|
| F-1 | CryptoAmountInput에 `mode` prop + `update:mode` emit 추가 | `components/common/CryptoAmountInput.vue` |
| F-2 | DepositSessionNewView "금액 단위" 셀렉트 제거 + `v-model:mode` 연동 | `deposits/DepositSessionNewView.vue` |
| F-3 | 기존 CryptoAmountInput 사용처 영향 없음 확인 (mode optional) | 전체 |
| T-9 | 입금 세션: USDT 탭 입력 → amountCurrency 빈값 확인 | 브라우저 |
| T-10 | 입금 세션: KRW 탭 입력 → amountCurrency 'KRW' 확인 | 브라우저 |

---

## 작업 순서 (권장)

```
① Part A (client.ts 확장)     ← 모든 OTP의 선행 조건
② Part B (8개 뷰 OTP 래핑)    ← A 완료 후
③ Part C (CryptoAmountInput)  ← A와 무관, 병렬 가능
④ Part D (로그인 이력 제거)    ← 독립 작업
⑤ Part E (FAILED 취소)        ← 독립 작업
⑥ Part F (금액 단위 셀렉트 제거) ← C 이후
⑦ 테스트                      ← 전체 완료 후
```
