import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Reorder, useDragControls } from 'framer-motion'
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
import { api } from '../../../api/client'
import ConfirmDialog from '../../../components/common/ConfirmDialog'
import Tooltip from '../../../components/common/Tooltip'
import CharacterSuggestDropdown from '../../../components/common/CharacterSuggestDropdown'
import { useFitText } from '../../../hooks/useFitText'
import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from '../admin/constants'
const MAX_PER_CHARACTER = 12
const MAX_PER_ACCOUNT = 90
function CharacterContent({ char, selections, bosses }) {
const bossIndex = new Map(bosses.map((b, i) => [b.id, i]))
const selectedBosses = Object.entries(selections || {})
.filter(([, sel]) => sel)
.map(([bossId, sel]) => {
const boss = bosses.find((b) => b.id === Number(bossId))
if (!boss) return null
const bd = boss.difficulties.find((d) => d.difficulty === sel.difficulty)
if (!bd) return null
return {
boss,
difficulty: sel.difficulty,
revenue: Math.floor(bd.crystal_price / sel.party),
}
})
.filter(Boolean)
// 12개 상한은 수익 높은 순으로 취한 뒤, 표시는 보스 목록 순서대로 정렬
const topByRevenue = [...selectedBosses].sort((a, b) => b.revenue - a.revenue).slice(0, MAX_PER_CHARACTER)
const visibleBosses = topByRevenue.sort(
(a, b) => (bossIndex.get(a.boss.id) ?? 0) - (bossIndex.get(b.boss.id) ?? 0)
)
const totalRevenue = visibleBosses.reduce((s, x) => s + x.revenue, 0)
const count = selectedBosses.length
return (
{char.character_image ? (

) : (
?
)}
{char.character_name}
Lv.{char.character_level} · {char.job_name}
{visibleBosses.length > 0 ? (
{visibleBosses.map((item) => {
const diff = DIFFICULTIES.find((d) => d.key === item.difficulty)
return (
)
})}
) : (
보스 미선택
)}
0 ? 'var(--warning-text-bright)' : 'var(--text-dim)' }}
>
{count}
0 ? 'var(--warning-text-dim)' : 'var(--text-dim)' }}
>
/ {MAX_PER_CHARACTER}
0 ? 'var(--accent-bright)' : 'var(--text-dim)' }}
>
{count > 0 ? formatMeso(totalRevenue) : '-'}
)
}
function CharacterItem({ char, isSelected, selections, bosses, onSelect, onRemove }) {
const [dragged, setDragged] = useState(false)
const dragControls = useDragControls()
return (
setDragged(true)}
onDragEnd={() => {
// 다음 click 이벤트 후에 reset
setTimeout(() => setDragged(false), 0)
}}
onClick={(e) => {
if (dragged) return
if (e.target.closest('button')) return
onSelect(char.character_name)
}}
className="group relative rounded-xl border cursor-pointer select-none"
style={{
borderColor: isSelected ? 'var(--selected-border)' : 'var(--panel-border)',
background: isSelected ? 'var(--selected-bg)' : 'var(--surface-3)',
}}
>
{/* 드래그 핸들 */}
{ e.preventDefault(); dragControls.start(e) }}
className="absolute left-0 top-0 bottom-0 w-8 flex items-center justify-center cursor-grab active:cursor-grabbing"
style={{ touchAction: 'none', color: 'var(--text-dim)' }}
>
)
}
export default function CharacterPanel({
characters, selectedName, allSelections, bosses,
onSelect, onAdd, onRemove, onReorder,
}) {
const [name, setName] = useState('')
const [error, setError] = useState('')
const [confirmRemove, setConfirmRemove] = useState(null)
const [dropdownOpen, setDropdownOpen] = useState(false)
const searchMutation = useMutation({
mutationFn: (n) => api(`/api/character/search?name=${encodeURIComponent(n)}`),
onSuccess: (data) => {
if (characters.find((c) => c.character_name === data.character_name)) {
setError('이미 추가된 캐릭터입니다')
return
}
onAdd(data)
setName('')
setError('')
},
onError: (err) => setError(err.message),
})
const handleSubmit = (e) => {
e.preventDefault()
if (!name.trim()) return
setError('')
searchMutation.mutate(name.trim())
}
// 총합 계산
const charResults = characters.map((char) => {
const charSel = allSelections[char.character_name] || {}
const items = Object.entries(charSel)
.filter(([, sel]) => sel)
.map(([bossId, sel]) => {
const boss = bosses.find((b) => b.id === Number(bossId))
if (!boss) return null
const bd = boss.difficulties.find((d) => d.difficulty === sel.difficulty)
if (!bd) return null
return Math.floor(bd.crystal_price / sel.party)
})
.filter(Boolean)
.sort((a, b) => b - a)
.slice(0, MAX_PER_CHARACTER)
return { count: items.length, revenue: items.reduce((s, v) => s + v, 0) }
})
const totalCount = charResults.reduce((s, r) => s + r.count, 0)
const totalRevenue = charResults.reduce((s, r) => s + r.revenue, 0)
const accountUsage = Math.min(totalCount, MAX_PER_ACCOUNT)
const usagePct = Math.min((accountUsage / MAX_PER_ACCOUNT) * 100, 100)
const totalText = formatMeso(totalRevenue)
const { containerRef: totalContainerRef, textRef: totalTextRef } = useFitText({
maxFontSize: 32,
minFontSize: 14,
value: totalText,
})
return (
{/* 총 수익 카드 (고정) */}
총 결정 개수
MAX_PER_ACCOUNT ? 'var(--progress-amber)' : 'var(--progress-emerald)',
}}
/>
MAX_PER_ACCOUNT ? 'var(--danger-text)' : 'var(--warning-text-bright)' }}
>
{accountUsage}
MAX_PER_ACCOUNT ? 'var(--danger-text)' : 'var(--warning-text-dim)',
opacity: totalCount > MAX_PER_ACCOUNT ? 0.4 : 1,
}}
>
/ {MAX_PER_ACCOUNT}
{totalCount > MAX_PER_ACCOUNT && (
⚠ 한도 {totalCount - MAX_PER_ACCOUNT}개 초과
)}
{/* 캐릭터 추가 (고정) */}
{/* 캐릭터 목록 (스크롤) */}
{characters.length > 0 && (
{characters.map((char) => (
))}
)}
setConfirmRemove(null)}
onConfirm={() => {
onRemove(confirmRemove.character_name)
setConfirmRemove(null)
}}
title="캐릭터 삭제"
description={confirmRemove ? `"${confirmRemove.character_name}" 캐릭터를 목록에서 삭제하시겠습니까?\n\n저장된 보스 선택도 함께 삭제됩니다.` : ''}
confirmText="삭제"
destructive
/>
)
}