# Admin 파트너 상세 화면 — 프론트엔드 구현 지침서

> **작성일**: 2026-04-05
> **대상**: `cryptoments-admin/admin-ui` (Vue 3 + TypeScript)
> **기준 문서**: `ADMIN_PARTNER_DETAIL_REDESIGN.md`
> **현재 코드**: `src/views/partners/PartnerDetailView.vue`

---

## 변경 요약

| 항목 | 현재 | 변경 후 |
|------|------|---------|
| 탭 구성 | 7탭 (기본정보, 지갑, 체인/통화, 출금정책, 연동설정, 하위파트너, 활동로그) | 7탭 (기본정보, **수수료**, 지갑, 체인/통화, 출금정책, 하위파트너, 활동로그) |
| 기본정보 탭 | 6개 Card (기본/연락처/계정/수수료/연동/설정) | 3개 Card (기본정보/계정정보/연동정보) |
| 수수료 탭 | 없음 (기본정보에 읽기전용 표시) | **신규** — 수수료 구조 + 범위 시각화 + 수정 폼 |
| 연동 설정 탭 | PartnerIntegrationTab (Axim + Telegram) | **제거** — Axim/Telegram을 기본정보 > 연동정보에 통합 |
| loginEmail | 읽기 전용 | 수정 가능 (editMode에서 Input) |
| 상태변경 | reason 미입력 | 정지/해지 시 사유 입력 모달 |
| 상단 액션 | [상태변경 ▾] [수정] | [상태변경 ▾] [수정] [더보기 ▾] |

---

## 1. 타입 수정

### `src/api/types/partner.ts`

```typescript
// PartnerDetail에 추가할 필드
export interface PartnerDetail {
  // ... 기존 필드 유지 ...

  /** 최소 수수료율 (자동 계산) */
  minFeeRate: string
  /** 2FA 활성화 여부 */
  twoFactorEnabled: boolean
  /** 마지막 로그인 시각 */
  lastLoginAt: string | null
  /** 마지막 로그인 IP */
  lastLoginIp: string | null
}

// PartnerUpdateRequest에 loginEmail 추가
export interface PartnerUpdateRequest {
  name?: string
  loginEmail?: string       // ← 추가
  contactEmail?: string
  contactPhone?: string
  webhookUrl?: string
  timezone?: string
  locale?: string
}

// 수수료 수정 요청 타입 (이미 partnerService.updateFees에 인라인 정의됨 → 별도 타입으로)
export interface PartnerFeeUpdateRequest {
  /** 상위 수수료율 (%) */
  parentFeeRate?: number
  /** 최대 수수료 상한 (%) */
  maxFeeCap?: number
  /** 입금 수수료율 (%) — MERCHANT만 */
  depositFeeRate?: number
}

// 수수료 변경 영향도 조회 응답
export interface FeeImpactResponse {
  /** 영향받는 하위 파트너 수 */
  affectedCount: number
  /** 영향받는 파트너 목록 (코드, 이름) */
  affectedPartners: { partnerCode: string; name: string }[]
}
```

---

## 2. API 서비스 수정

### `src/api/services/partner.service.ts`

```typescript
// 기존 updateFees 시그니처를 PartnerFeeUpdateRequest로 변경
updateFees(id: number, data: PartnerFeeUpdateRequest) {
  return apiClient.put(`${BASE}/${id}/fees`, data)
},

// 추가: 수수료 변경 영향도 조회 (선택 — 백엔드 구현 시)
getFeeImpact(id: number, params: { maxFeeCap?: number }) {
  return apiClient.get<FeeImpactResponse>(`${BASE}/${id}/fees/impact`, { params })
},

// 추가: 비밀번호 초기화
resetPassword(id: number) {
  return apiClient.post<{ tempPassword: string }>(`${BASE}/${id}/password/reset`)
},

// 추가: 파트너 삭제
deletePartner(id: number) {
  return apiClient.delete(`${BASE}/${id}`)
},
```

---

## 3. 탭 구성 변경

### `PartnerDetailView.vue` — TabsList 수정

```html
<TabsList>
  <TabsTrigger value="basic">기본정보</TabsTrigger>
  <TabsTrigger value="fees">수수료</TabsTrigger>        <!-- 신규 -->
  <TabsTrigger value="wallets">지갑</TabsTrigger>
  <TabsTrigger value="chain">체인/통화</TabsTrigger>
  <TabsTrigger value="withdrawal">출금정책</TabsTrigger>
  <TabsTrigger v-if="isDistributor" value="children">하위 파트너</TabsTrigger>
  <TabsTrigger value="logs">활동 로그</TabsTrigger>
</TabsList>
```

**제거**: `integration` 탭 (PartnerIntegrationTab).
**추가**: `fees` 탭 (PartnerFeeTab — 신규 컴포넌트).

---

## 4. 기본정보 탭 재구성

기존 6개 Card → 3개 섹션(Card)로 통합.

### 4-1. 기본 정보 섹션

| 필드 | 수정 | 비고 |
|------|------|------|
| partnerCode | ✕ | 읽기 전용 |
| name | ✅ | editMode에서 Input |
| partnerType | ✕ | DISTRIBUTOR/MERCHANT 뱃지 |
| status | ✅ | 상태변경 버튼 (별도) |
| parentPartnerName | ✕ | 클릭 시 `router.push({ name: 'partner-detail', params: { id: partner.parentPartnerId }})` |
| createdAt | ✕ | |
| updatedAt | ✕ | |

```html
<Card>
  <CardHeader>
    <CardTitle class="text-lg">기본 정보</CardTitle>
  </CardHeader>
  <CardContent>
    <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
      <!-- partnerCode: 읽기 전용 -->
      <div class="space-y-1">
        <p class="text-sm text-muted-foreground">파트너 코드</p>
        <p class="text-sm font-medium font-mono">{{ partner.partnerCode }}</p>
      </div>

      <!-- name: 수정 가능 -->
      <div class="space-y-1">
        <p class="text-sm text-muted-foreground">파트너명</p>
        <template v-if="editMode">
          <Input v-model="editName" v-bind="editNameAttrs"
                 :class="{ 'border-red-500': errors.name }" />
          <p v-if="errors.name" class="text-sm text-red-500">{{ errors.name }}</p>
        </template>
        <p v-else class="text-sm font-medium">{{ partner.name }}</p>
      </div>

      <!-- partnerType: 뱃지 -->
      <div class="space-y-1">
        <p class="text-sm text-muted-foreground">유형</p>
        <Badge :variant="partner.partnerType === 'DISTRIBUTOR' ? 'default' : 'secondary'">
          {{ partner.partnerType === 'DISTRIBUTOR' ? '총판' : '가맹점' }}
        </Badge>
      </div>

      <!-- status: 뱃지 -->
      <div class="space-y-1">
        <p class="text-sm text-muted-foreground">상태</p>
        <StatusBadge :status="partner.status" />
      </div>

      <!-- parentPartnerName: 링크 -->
      <div class="space-y-1">
        <p class="text-sm text-muted-foreground">상위 파트너</p>
        <RouterLink v-if="partner.parentPartnerId"
                    :to="{ name: 'partner-detail', params: { id: partner.parentPartnerId }}"
                    class="text-sm font-medium text-blue-600 hover:underline">
          {{ partner.parentPartnerName }}
        </RouterLink>
        <p v-else class="text-sm font-medium text-muted-foreground">-</p>
      </div>

      <!-- createdAt / updatedAt -->
      <div class="space-y-1">
        <p class="text-sm text-muted-foreground">등록일</p>
        <DateDisplay :date="partner.createdAt" />
      </div>
      <div class="space-y-1">
        <p class="text-sm text-muted-foreground">수정일</p>
        <DateDisplay :date="partner.updatedAt" />
      </div>
    </div>
  </CardContent>
</Card>
```

### 4-2. 계정 정보 섹션

| 필드 | 수정 | 비고 |
|------|------|------|
| loginEmail | ✅ | editMode에서 Input + 서버 중복 검사 |
| twoFactorEnabled | ✕ | ON/OFF 뱃지 + [초기화] 버튼 |
| lastLoginAt | ✕ | |
| lastLoginIp | ✕ | |

```html
<Card>
  <CardHeader>
    <CardTitle class="text-lg">계정 정보</CardTitle>
  </CardHeader>
  <CardContent>
    <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
      <!-- loginEmail: 수정 가능 -->
      <div class="space-y-1">
        <p class="text-sm text-muted-foreground">로그인 이메일</p>
        <template v-if="editMode">
          <Input v-model="editLoginEmail" v-bind="editLoginEmailAttrs"
                 type="email"
                 :class="{ 'border-red-500': errors.loginEmail }" />
          <p v-if="errors.loginEmail" class="text-sm text-red-500">{{ errors.loginEmail }}</p>
        </template>
        <p v-else class="text-sm font-medium">{{ partner.loginEmail }}</p>
      </div>

      <!-- 2FA -->
      <div class="space-y-1">
        <p class="text-sm text-muted-foreground">2FA</p>
        <div class="flex items-center gap-2">
          <Badge :variant="partner.twoFactorEnabled ? 'default' : 'outline'">
            {{ partner.twoFactorEnabled ? 'ON' : 'OFF' }}
          </Badge>
          <Button v-if="partner.twoFactorEnabled && !editMode"
                  variant="outline" size="sm"
                  @click="handleReset2fa">
            초기화
          </Button>
        </div>
      </div>

      <!-- lastLoginAt -->
      <div class="space-y-1">
        <p class="text-sm text-muted-foreground">마지막 로그인</p>
        <DateDisplay v-if="partner.lastLoginAt" :date="partner.lastLoginAt" />
        <p v-else class="text-sm text-muted-foreground">-</p>
      </div>

      <!-- lastLoginIp -->
      <div class="space-y-1">
        <p class="text-sm text-muted-foreground">마지막 로그인 IP</p>
        <p class="text-sm font-medium font-mono">{{ partner.lastLoginIp ?? '-' }}</p>
      </div>
    </div>
  </CardContent>
</Card>
```

### 4-3. 연동 정보 섹션

기존 연동 Card의 API Key + Webhook + 타임존/로케일을 여기로 이동.

| 필드 | 수정 | 비고 |
|------|------|------|
| apiKey | ✕ | 마스킹 표시 + [복사] + [재발급] — 기존 코드 유지 |
| webhookUrl | ✅ | editMode에서 Input |
| timezone | ✅ | editMode에서 Select |
| locale | ✅ | editMode에서 Select |

```html
<Card>
  <CardHeader>
    <CardTitle class="text-lg">연동 정보</CardTitle>
  </CardHeader>
  <CardContent class="space-y-4">
    <!-- API Key (기존 코드 재사용) -->
    <div class="space-y-1">
      <p class="text-sm text-muted-foreground">API Key</p>
      <div class="flex items-center gap-2">
        <code class="rounded bg-muted px-3 py-1.5 text-sm font-mono">{{ partner.apiKey }}</code>
        <Button variant="outline" size="icon-sm" @click="copy(partner.apiKey)">
          <Copy class="h-3.5 w-3.5" />
        </Button>
      </div>
    </div>

    <Separator />

    <!-- Webhook URL -->
    <div class="space-y-1">
      <p class="text-sm text-muted-foreground">Webhook URL</p>
      <template v-if="editMode">
        <Input v-model="editWebhookUrl" v-bind="editWebhookUrlAttrs"
               placeholder="https://..."
               :class="{ 'border-red-500': errors.webhookUrl }" />
        <p v-if="errors.webhookUrl" class="text-sm text-red-500">{{ errors.webhookUrl }}</p>
      </template>
      <p v-else class="text-sm font-medium">{{ partner.webhookUrl ?? '-' }}</p>
    </div>

    <Separator />

    <!-- 타임존 / 로케일 -->
    <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
      <div class="space-y-1">
        <p class="text-sm text-muted-foreground">타임존</p>
        <template v-if="editMode">
          <Select v-model="editTimezone">
            <SelectTrigger><SelectValue placeholder="타임존 선택" /></SelectTrigger>
            <SelectContent>
              <SelectItem value="Asia/Seoul">Asia/Seoul</SelectItem>
              <SelectItem value="Asia/Tokyo">Asia/Tokyo</SelectItem>
              <SelectItem value="UTC">UTC</SelectItem>
              <SelectItem value="America/New_York">America/New_York</SelectItem>
              <SelectItem value="Europe/London">Europe/London</SelectItem>
            </SelectContent>
          </Select>
        </template>
        <p v-else class="text-sm font-medium">{{ partner.timezone }}</p>
      </div>
      <div class="space-y-1">
        <p class="text-sm text-muted-foreground">로케일</p>
        <template v-if="editMode">
          <Select v-model="editLocale">
            <SelectTrigger><SelectValue placeholder="로케일 선택" /></SelectTrigger>
            <SelectContent>
              <SelectItem value="ko">한국어 (ko)</SelectItem>
              <SelectItem value="en">English (en)</SelectItem>
            </SelectContent>
          </Select>
        </template>
        <p v-else class="text-sm font-medium">{{ partner.locale }}</p>
      </div>
    </div>
  </CardContent>
</Card>
```

### 4-4. editSchema 수정

loginEmail 필드 추가:

```typescript
const editSchema = toTypedSchema(
  z.object({
    name: z.string().min(2, '2자 이상').max(200, '200자 이하'),
    loginEmail: z.string().email('올바른 이메일 형식'),     // ← 추가
    webhookUrl: z.string()
      .refine((v) => !v || v.startsWith('https://'), 'https://로 시작해야 합니다.')
      .optional().or(z.literal('')),
    timezone: z.string(),
    locale: z.string(),
  }),
)

// defineField 추가
const [editLoginEmail, editLoginEmailAttrs] = defineField('loginEmail')
```

### 4-5. enterEditMode / onSave 수정

```typescript
function enterEditMode() {
  if (!partner.value) return
  resetForm({
    values: {
      name: partner.value.name,
      loginEmail: partner.value.loginEmail,   // ← 추가
      webhookUrl: partner.value.webhookUrl ?? '',
      timezone: partner.value.timezone,
      locale: partner.value.locale,
    },
  })
  editMode.value = true
}

const onSave = handleSubmit(async (formValues) => {
  saving.value = true
  try {
    const payload: PartnerUpdateRequest = {
      name: formValues.name,
      loginEmail: formValues.loginEmail,       // ← 추가
      webhookUrl: formValues.webhookUrl || undefined,
      timezone: formValues.timezone,
      locale: formValues.locale,
    }
    const res = await partnerService.update(id, payload)
    partner.value = res.data
    editMode.value = false
    toast.success('파트너 정보가 수정되었습니다.')
  } catch (err: any) {
    if (err?.response?.data?.code === '2002') {
      toast.error('이미 사용 중인 이메일입니다.')
    } else {
      throw err
    }
  } finally {
    saving.value = false
  }
})
```

---

## 5. 수수료 탭 (신규)

### 파일: `src/views/partners/tabs/PartnerFeeTab.vue`

```vue
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { toast } from 'vue-sonner'
import { partnerService } from '@/api/services/partner.service'
import type { PartnerDetail, PartnerFeeUpdateRequest } from '@/api/types/partner'
import { useConfirm } from '@/composables/useConfirm'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Pencil, Save, X, Info } from 'lucide-vue-next'

const props = defineProps<{
  partnerId: number
  partner: PartnerDetail
}>()

const emit = defineEmits<{
  (e: 'updated'): void
}>()

const { confirm } = useConfirm()

const editMode = ref(false)
const saving = ref(false)

// 수정 폼 데이터
const form = ref({
  parentFeeRate: '',
  maxFeeCap: '',
  depositFeeRate: '',
})

const isMerchant = computed(() => props.partner.partnerType === 'MERCHANT')
const isDistributor = computed(() => props.partner.partnerType === 'DISTRIBUTOR')

// 시각화 바 계산
const minRate = computed(() => parseFloat(props.partner.minFeeRate) || 0)
const maxCap = computed(() => parseFloat(props.partner.maxFeeCap) || 0)
const depositRate = computed(() => parseFloat(props.partner.depositFeeRate ?? '0') || 0)

// 범위 바에서 위치 계산 (0~maxCap 범위 기준, 최대 표시 범위는 maxCap의 120%)
const barMax = computed(() => Math.max(maxCap.value * 1.2, 1))
const minPosition = computed(() => (minRate.value / barMax.value) * 100)
const maxPosition = computed(() => (maxCap.value / barMax.value) * 100)
const depositPosition = computed(() => (depositRate.value / barMax.value) * 100)

function enterEditMode() {
  form.value = {
    parentFeeRate: props.partner.parentFeeRate,
    maxFeeCap: props.partner.maxFeeCap,
    depositFeeRate: props.partner.depositFeeRate ?? '',
  }
  editMode.value = true
}

function cancelEdit() {
  editMode.value = false
}

async function handleSave() {
  const newParentFee = parseFloat(form.value.parentFeeRate)
  const newMaxCap = parseFloat(form.value.maxFeeCap)
  const newDepositFee = form.value.depositFeeRate ? parseFloat(form.value.depositFeeRate) : undefined

  // 클라이언트 유효성 검사
  if (isNaN(newParentFee) || newParentFee < 0) {
    toast.error('상위 수수료율은 0 이상이어야 합니다.')
    return
  }
  if (isNaN(newMaxCap) || newMaxCap < 0) {
    toast.error('최대 수수료 상한은 0 이상이어야 합니다.')
    return
  }
  if (newDepositFee !== undefined) {
    const computedMin = minRate.value // 자동 계산이므로 서버에서 반환될 값 사용
    if (newDepositFee < computedMin || newDepositFee > newMaxCap) {
      toast.error(`실제 수수료율은 ${computedMin}% ~ ${newMaxCap}% 범위여야 합니다.`)
      return
    }
  }

  // parentFeeRate 변경 시 하위 파트너 영향 경고
  if (form.value.parentFeeRate !== props.partner.parentFeeRate) {
    const confirmed = await confirm({
      title: '수수료율 변경 경고',
      description: '상위 수수료율을 변경하면 하위 파트너의 최소 수수료율(minFeeRate)에 연쇄 영향을 줍니다. 계속하시겠습니까?',
      variant: 'destructive',
      confirmText: '변경',
    })
    if (!confirmed) return
  }

  saving.value = true
  try {
    const payload: PartnerFeeUpdateRequest = {
      parentFeeRate: newParentFee,
      maxFeeCap: newMaxCap,
    }
    if (isMerchant.value && newDepositFee !== undefined) {
      payload.depositFeeRate = newDepositFee
    }
    await partnerService.updateFees(props.partnerId, payload)
    toast.success('수수료가 수정되었습니다.')
    editMode.value = false
    emit('updated')
  } finally {
    saving.value = false
  }
}

</script>

<template>
  <Card>
    <CardHeader class="flex flex-row items-center justify-between">
      <CardTitle class="text-lg">수수료 구조</CardTitle>
      <div v-if="!editMode" class="flex gap-2">
        <Button variant="outline" size="sm" @click="enterEditMode">
          <Pencil class="mr-1 h-4 w-4" />
          수정
        </Button>
      </div>
      <div v-else class="flex gap-2">
        <Button variant="outline" size="sm" @click="cancelEdit">
          <X class="mr-1 h-4 w-4" /> 취소
        </Button>
        <Button size="sm" :disabled="saving" @click="handleSave">
          <Save class="mr-1 h-4 w-4" />
          {{ saving ? '저장 중...' : '저장' }}
        </Button>
      </div>
    </CardHeader>
    <CardContent class="space-y-6">

      <!-- 수수료 필드 그리드 -->
      <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
        <!-- parentFeeRate -->
        <div class="space-y-1">
          <Label class="text-sm text-muted-foreground">상위 수수료율 (parentFeeRate)</Label>
          <template v-if="editMode">
            <div class="flex items-center gap-2">
              <Input v-model="form.parentFeeRate" type="number" step="0.01" min="0"
                     class="w-32" />
              <span class="text-sm">%</span>
            </div>
          </template>
          <p v-else class="text-sm font-medium">{{ partner.parentFeeRate }} %</p>
        </div>

        <!-- minFeeRate (읽기 전용) -->
        <div class="space-y-1">
          <Label class="text-sm text-muted-foreground">
            최소 수수료율 (minFeeRate)
            <span class="text-xs text-muted-foreground ml-1">← 자동 계산</span>
          </Label>
          <p class="text-sm font-medium text-muted-foreground">{{ partner.minFeeRate }} %</p>
        </div>

        <!-- maxFeeCap -->
        <div class="space-y-1">
          <Label class="text-sm text-muted-foreground">최대 수수료 상한 (maxFeeCap)</Label>
          <template v-if="editMode">
            <div class="flex items-center gap-2">
              <Input v-model="form.maxFeeCap" type="number" step="0.01" min="0"
                     class="w-32" />
              <span class="text-sm">%</span>
            </div>
          </template>
          <p v-else class="text-sm font-medium">{{ partner.maxFeeCap }} %</p>
        </div>

        <!-- depositFeeRate (MERCHANT만) -->
        <div v-if="isMerchant" class="space-y-1">
          <Label class="text-sm text-muted-foreground">실제 수수료율 (depositFeeRate)</Label>
          <template v-if="editMode">
            <div class="flex items-center gap-2">
              <Input v-model="form.depositFeeRate" type="number" step="0.01" min="0"
                     class="w-32" />
              <span class="text-sm">%</span>
            </div>
          </template>
          <p v-else class="text-sm font-medium">{{ partner.depositFeeRate ?? '-' }}{{ partner.depositFeeRate ? ' %' : '' }}</p>
        </div>
      </div>

      <!-- 시각적 범위 바 -->
      <div v-if="maxCap > 0" class="space-y-2">
        <p class="text-sm font-medium text-muted-foreground">수수료 범위</p>
        <div class="relative h-8 bg-gray-100 rounded-lg overflow-hidden">
          <!-- 허용 범위 (min ~ max) -->
          <div class="absolute h-full bg-blue-100 rounded"
               :style="{ left: minPosition + '%', width: (maxPosition - minPosition) + '%' }" />
          <!-- min 마커 -->
          <div class="absolute h-full w-0.5 bg-blue-400"
               :style="{ left: minPosition + '%' }" />
          <!-- max 마커 -->
          <div class="absolute h-full w-0.5 bg-blue-400"
               :style="{ left: maxPosition + '%' }" />
          <!-- depositFeeRate 마커 (MERCHANT만) -->
          <div v-if="isMerchant && depositRate > 0"
               class="absolute h-full w-1.5 bg-green-500 rounded"
               :style="{ left: depositPosition + '%' }" />
        </div>
        <div class="flex justify-between text-xs text-muted-foreground">
          <span>0%</span>
          <span>min: {{ partner.minFeeRate }}%</span>
          <span v-if="isMerchant">실제: {{ partner.depositFeeRate }}%</span>
          <span>max: {{ partner.maxFeeCap }}%</span>
        </div>
      </div>

      <!-- 안내 문구 -->
      <div class="flex items-start gap-2 rounded-lg bg-blue-50 p-3">
        <Info class="h-4 w-4 text-blue-500 mt-0.5 shrink-0" />
        <p v-if="isDistributor" class="text-sm text-blue-700">
          하위 파트너는 {{ partner.minFeeRate }}% ~ {{ partner.maxFeeCap }}% 범위에서
          수수료를 설정할 수 있습니다.
        </p>
        <p v-else class="text-sm text-blue-700">
          이 파트너의 입금 수수료율은 {{ partner.minFeeRate }}% ~ {{ partner.maxFeeCap }}%
          범위에서 설정할 수 있습니다.
        </p>
      </div>

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

### PartnerDetailView.vue에 탭 연결

```html
<!-- Tab: 수수료 -->
<TabsContent value="fees">
  <PartnerFeeTab :partner-id="id" :partner="partner" @updated="fetchPartner" />
</TabsContent>
```

```typescript
// import 추가
import PartnerFeeTab from '@/views/partners/tabs/PartnerFeeTab.vue'
```

---

## 6. 상단 액션 버튼 변경

### "더보기" 드롭다운 메뉴 추가

기존 [상태변경 ▾] [수정] 옆에 [더보기 ▾] 추가.

```html
<template #actions>
  <template v-if="partner && !editMode">
    <!-- 상태변경 (기존 유지) -->
    <DropdownMenu v-if="availableStatusTransitions.length > 0">
      <!-- ... 기존 코드 유지 ... -->
    </DropdownMenu>

    <!-- 수정 버튼 (기존 유지) -->
    <Button variant="outline" size="sm" @click="enterEditMode">
      <Pencil class="mr-1 h-4 w-4" /> 수정
    </Button>

    <!-- 더보기 메뉴 (신규) -->
    <DropdownMenu>
      <DropdownMenuTrigger as-child>
        <Button variant="outline" size="sm">
          더보기 <ChevronDown class="ml-1 h-4 w-4" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem @click="handleRegenerateApiKey">
          API Key 재발급
        </DropdownMenuItem>
        <DropdownMenuItem @click="handleReset2fa">
          2FA 초기화
        </DropdownMenuItem>
        <DropdownMenuItem @click="handleResetPassword">
          비밀번호 초기화
        </DropdownMenuItem>
        <DropdownMenuSeparator />
        <DropdownMenuItem class="text-red-600" @click="handleDeletePartner">
          파트너 삭제
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  </template>

  <!-- 수정 모드 버튼 (기존 유지) -->
  <template v-if="editMode">
    <!-- ... 기존 코드 유지 ... -->
  </template>
</template>
```

### 추가 핸들러

```typescript
// 2FA 초기화 (API: POST /api/admin/partners/{id}/2fa/reset)
async function handleReset2fa() {
  const confirmed = await confirm({
    title: '2FA 초기화',
    description: '이 파트너의 2FA 인증을 초기화하시겠습니까?',
    confirmText: '초기화',
  })
  if (!confirmed) return
  await partnerService.reset2fa(id)
  toast.success('2FA가 초기화되었습니다.')
  await fetchPartner()
}

// 비밀번호 초기화 (API: POST /api/admin/partners/{id}/password/reset)
async function handleResetPassword() {
  const confirmed = await confirm({
    title: '비밀번호 초기화',
    description: '임시 비밀번호를 발급하시겠습니까?',
    confirmText: '초기화',
  })
  if (!confirmed) return
  const res = await partnerService.resetPassword(id)
  // 임시 비밀번호를 다이얼로그로 표시
  tempPassword.value = res.data.tempPassword
  showTempPasswordDialog.value = true
}

// 파트너 삭제 (API: DELETE /api/admin/partners/{id})
async function handleDeletePartner() {
  const confirmed = await confirm({
    title: '파트너 삭제',
    description: `"${partner.value?.name}"을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.`,
    variant: 'destructive',
    confirmText: '삭제',
  })
  if (!confirmed) return
  await partnerService.deletePartner(id)
  toast.success('파트너가 삭제되었습니다.')
  router.push({ name: 'partner-list' })
}
```

### 추가 ref/다이얼로그

```typescript
const showTempPasswordDialog = ref(false)
const tempPassword = ref('')
```

```html
<!-- 임시 비밀번호 다이얼로그 -->
<Dialog :open="showTempPasswordDialog"
        @update:open="(v) => { if (!v) showTempPasswordDialog = false }">
  <DialogContent class="sm:max-w-md">
    <DialogHeader>
      <DialogTitle>임시 비밀번호 발급 완료</DialogTitle>
      <DialogDescription>
        아래 임시 비밀번호를 파트너에게 전달해주세요. 이 화면에서만 확인할 수 있습니다.
      </DialogDescription>
    </DialogHeader>
    <div class="space-y-2">
      <Label class="text-sm font-medium">임시 비밀번호</Label>
      <div class="flex items-center gap-2">
        <code class="flex-1 rounded bg-muted px-3 py-2 text-sm font-mono">
          {{ tempPassword }}
        </code>
        <Button variant="outline" size="icon" @click="copy(tempPassword)">
          <Copy class="h-4 w-4" />
        </Button>
      </div>
    </div>
    <DialogFooter>
      <Button @click="showTempPasswordDialog = false">확인</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>
```

---

## 7. 상태변경 — 사유 입력 모달

현재 `handleStatusChange`는 간단한 confirm만 사용. 정지/해지 시 사유 입력 모달로 변경.

```typescript
const showStatusReasonDialog = ref(false)
const pendingStatus = ref<PartnerStatus | null>(null)
const statusReason = ref('')

async function handleStatusChange(newStatus: PartnerStatus) {
  if (!partner.value) return

  // 정지, 해지 시 사유 입력 모달
  if (newStatus === 'SUSPENDED' || newStatus === 'TERMINATED') {
    pendingStatus.value = newStatus
    statusReason.value = ''
    showStatusReasonDialog.value = true
    return
  }

  // 활성화 — 간단 확인
  const confirmed = await confirm({
    title: '상태 변경',
    description: `${partner.value.name}을(를) 활성화하시겠습니까?`,
    confirmText: '활성화',
  })
  if (!confirmed) return

  await partnerService.changeStatus(id, { status: newStatus })
  toast.success('상태가 변경되었습니다.')
  await fetchPartner()
}

async function confirmStatusChange() {
  if (!pendingStatus.value) return
  await partnerService.changeStatus(id, {
    status: pendingStatus.value,
    reason: statusReason.value || undefined,
  })
  showStatusReasonDialog.value = false
  toast.success('상태가 변경되었습니다.')
  await fetchPartner()
}
```

```html
<!-- 상태변경 사유 입력 다이얼로그 -->
<Dialog :open="showStatusReasonDialog"
        @update:open="(v) => { if (!v) showStatusReasonDialog = false }">
  <DialogContent class="sm:max-w-md">
    <DialogHeader>
      <DialogTitle>
        {{ pendingStatus === 'TERMINATED' ? '파트너 해지' : '파트너 정지' }}
      </DialogTitle>
      <DialogDescription>
        {{ pendingStatus === 'TERMINATED'
            ? '이 작업은 되돌릴 수 없습니다. 사유를 입력해주세요.'
            : '정지 사유를 입력해주세요. (선택)' }}
      </DialogDescription>
    </DialogHeader>
    <div class="space-y-2">
      <Label>사유</Label>
      <Input v-model="statusReason" placeholder="사유 입력..." />
    </div>
    <DialogFooter>
      <Button variant="outline" @click="showStatusReasonDialog = false">취소</Button>
      <Button :variant="pendingStatus === 'TERMINATED' ? 'destructive' : 'default'"
              @click="confirmStatusChange">
        {{ pendingStatus === 'TERMINATED' ? '해지' : '정지' }}
      </Button>
    </DialogFooter>
  </DialogContent>
</Dialog>
```

---

## 8. TERMINATED 상태 비활성화

TERMINATED인 파트너는 모든 수정 버튼을 비활성화.

```typescript
const isTerminated = computed(() => partner.value?.status === 'TERMINATED')
```

```html
<!-- 상단 액션에 적용 -->
<template v-if="partner && !editMode && !isTerminated">
  <!-- 상태변경, 수정, 더보기 버튼들 -->
</template>
<template v-if="isTerminated">
  <Badge variant="destructive">해지됨 — 수정 불가</Badge>
</template>
```

---

## 9. 제거할 파일/코드

| 대상 | 조치 |
|------|------|
| `PartnerIntegrationTab.vue` | 삭제 — Axim/Telegram은 별도 탭으로 유지할 필요 없음, 기본정보 연동 섹션에 통합 |
| 기본정보 탭의 "연락처", "수수료", "설정" Card | 제거 — 재구성된 3개 섹션으로 대체 |
| `import PartnerIntegrationTab` | 제거 |
| TabsTrigger value="integration" | 제거 |
| TabsContent value="integration" | 제거 |

> **참고**: Axim/Telegram 설정은 현재 사용 빈도가 낮으므로, 일단 기본정보 탭 내 "연동 정보" 섹션에는 API Key + Webhook + 타임존/로케일만 포함.
> Axim/Telegram 상세 설정이 필요한 경우, "연동 정보" Card 하단에 "Axim 설정" / "Telegram 설정" 확장 패널을 추가하거나, 별도 탭을 유지할 수 있음.

---

## 10. 파일 변경 목록

| 파일 | 변경 유형 |
|------|-----------|
| `src/api/types/partner.ts` | 수정 — PartnerDetail에 필드 추가, PartnerUpdateRequest에 loginEmail, PartnerFeeUpdateRequest 신규 |
| `src/api/services/partner.service.ts` | 수정 — updateFees 시그니처, resetPassword/deletePartner/getFeeImpact 추가 |
| `src/views/partners/PartnerDetailView.vue` | 대폭 수정 — 탭 재구성, 기본정보 3섹션, 상단 더보기 메뉴, 상태변경 사유 모달 |
| `src/views/partners/tabs/PartnerFeeTab.vue` | **신규** — 수수료 구조 + 시각화 + 수정 |
| `src/views/partners/tabs/PartnerIntegrationTab.vue` | 삭제 (또는 Axim/Telegram 유지 시 축소) |

---

## 11. 구현 순서 권장

1. **타입 정의 수정** (`partner.ts`) — 모든 컴포넌트의 기반
2. **API 서비스 수정** (`partner.service.ts`) — 새 API 메서드 추가
3. **PartnerFeeTab.vue 신규 생성** — 독립 컴포넌트이므로 먼저 완성
4. **PartnerDetailView.vue 수정**:
   a. 탭 구성 변경
   b. 기본정보 탭 3섹션 재구성
   c. 상단 "더보기" 메뉴 추가
   d. 상태변경 사유 모달 추가
   e. TERMINATED 비활성화 처리
5. **PartnerIntegrationTab 정리** — 삭제 또는 축소

---

## 12. 백엔드 PartnerDetailResponse — 이미 완료

`PartnerDetailResponse`에 필요한 필드가 **이미 모두 존재**:

| 필드 | 백엔드 상태 | 프론트 타입 |
|------|------------|------------|
| minFeeRate | ✅ `BigDecimal` | 타입에 추가 필요 |
| twoFactorEnabled | ✅ `Boolean` | 타입에 추가 필요 |
| lastLoginAt | ✅ `LocalDateTime` | 타입에 추가 필요 |
| lastLoginIp | ✅ `String` | 타입에 추가 필요 |
| activatedAt | ✅ `LocalDateTime` | (선택) |
| suspendedAt | ✅ `LocalDateTime` | (선택) |
| suspendReason | ✅ `String` | (선택) |

→ 백엔드 수정 불필요. **프론트 `PartnerDetail` 인터페이스에 위 필드를 추가하면 즉시 사용 가능.**
