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/ConfirmDialog' import Tooltip from '../../../components/Tooltip' 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 (
{diff?.initial}
) })}
) : (
보스 미선택
)}
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 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 (
{/* 총 수익 카드 (고정) */}
총 주간 수익
{totalText}
총 결정 개수
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}개 초과

)}
{/* 캐릭터 추가 (고정) */}
{ setName(e.target.value); if (error) setError('') }} placeholder="캐릭터 닉네임 검색" className="w-full rounded-lg border-2 pl-10 pr-3 py-2.5 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]" style={{ background: 'var(--input-bg)', borderColor: 'var(--input-border)', color: 'var(--text-strong)', }} />
{error && (

{error}

)}
{/* 캐릭터 목록 (스크롤) */} {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 />
) }