import { useState, useEffect, useMemo } from 'react'
import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import { api } from '../../api/client'
import { useLayout } from '../../components/Layout'
import Select from '../../components/Select'
import Tooltip from '../../components/Tooltip'
import { useSymbolStore } from './store'
dayjs.extend(utc)
dayjs.extend(timezone)
const KST = 'Asia/Seoul'
const DOW = ['일', '월', '화', '수', '목', '금', '토']
function formatKoreanDate(d) {
const dj = dayjs(d).tz(KST)
return `${dj.year()}년 ${String(dj.month() + 1).padStart(2, '0')}월 ${String(dj.date()).padStart(2, '0')}일 (${DOW[dj.day()]})`
}
/**
* 심볼 완료까지 남은 일수/예상 완료일 계산
* - 일퀘는 매일, 주간퀘는 매주 목요일 리셋 시 N회분을 한 번에 지급한다고 가정
* - extra(추가 심볼)는 즉시 적용
* - dailyDone이면 오늘 일퀘는 이미 받은 걸로 간주 (내일부터 다시 지급)
*/
function computeCompletion({ remainingSymbols, daily, weeklyPerWeek, extra, dailyDone }) {
const need = Math.max(remainingSymbols - extra, 0)
if (need === 0) return { days: 0, date: dayjs().tz(KST).startOf('day').toDate() }
if (daily <= 0 && weeklyPerWeek <= 0) return { days: null, date: null }
let acc = 0
let cursor = dayjs().tz(KST).startOf('day')
for (let day = 0; day < 3650; day++) {
// 오늘은 dailyDone이면 일퀘 없음, 그 외엔 daily
if (!(day === 0 && dailyDone)) acc += daily
// 목요일(day=4)에 주간퀘 전량 지급
if (cursor.day() === 4 && weeklyPerWeek > 0) acc += weeklyPerWeek
if (acc >= need) return { days: day, date: cursor.toDate() }
cursor = cursor.add(1, 'day')
}
return { days: null, date: null }
}
function formatMesoKorean(n) {
const v = Number(n) || 0
if (v <= 0) return '0'
const eok = Math.floor(v / 100_000_000)
const man = Math.floor((v % 100_000_000) / 10_000)
const parts = []
if (eok) parts.push(`${eok.toLocaleString()}억`)
if (man) parts.push(`${man.toLocaleString()}만`)
return parts.length ? parts.join(' ') : v.toLocaleString()
}
const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱']
function CharacterCard({ char, active, onSelect, onRemove }) {
return (
{
if (e.target.closest('button')) return
onSelect()
}}
className={`group relative shrink-0 w-36 rounded-xl border cursor-pointer select-none transition ${
active
? 'border-emerald-500/40 bg-emerald-500/[0.08]'
: 'border-white/5 hover:border-white/15 bg-gray-950/40 hover:bg-gray-950/60'
}`}
>
{/* 삭제 (우상단) */}
{/* 내용 */}
{char.character_image ? (

) : (
?
)}
{char.character_name}
Lv.{char.character_level} · {char.job_name}
)
}
function SymbolCard({ symbol, equipped, charId }) {
const progress = useSymbolStore((s) => s.progress?.[charId]?.[symbol.id])
const updateSymbol = useSymbolStore((s) => s.updateSymbol)
const dailyDone = progress?.dailyDone ?? false
const weeklyCount = progress?.weeklyCount ?? 3
const daily = progress?.daily ?? symbol.daily_default
const extra = progress?.extra ?? 0
const patch = (p) => charId && updateSymbol(charId, symbol.id, p)
const level = progress?.level ?? 0
const growth = progress?.growth ?? 0
const requireGrowth = symbol.levels?.find((l) => l.level === level)?.required_count || 0
const isMax = equipped && level >= symbol.max_level
const interactable = equipped && !isMax
// 남은 심볼: 현재 레벨→만렙 까지 필요한 심볼 총합 (현재 성장치 차감)
// 필요 메소: 현재 레벨→만렙 까지 필요한 메소 총합
// 체납 메소: 이미 성장치가 현재 레벨 요구치 이상이면 바로 올릴 수 있는 레벨의 메소
const { remainingSymbols, remainingMeso, arrearMeso } = useMemo(() => {
if (!equipped || !symbol.levels?.length) return { remainingSymbols: 0, remainingMeso: 0, arrearMeso: 0 }
let sym = 0, meso = 0, arr = 0
for (const l of symbol.levels) {
if (l.level < level) continue
if (l.level === level) {
sym += Math.max(l.required_count - growth, 0)
meso += l.meso_cost
if (growth >= l.required_count) arr += l.meso_cost
} else {
sym += l.required_count
meso += l.meso_cost
}
}
return { remainingSymbols: sym, remainingMeso: meso, arrearMeso: arr }
}, [equipped, level, growth, symbol.levels])
// 현재 성장치로 도달 가능한 최대 레벨 (연속 체납 반영)
const reachableLevel = useMemo(() => {
if (!equipped || isMax) return level
let lv = level
let g = growth
while (lv < symbol.max_level) {
const req = symbol.levels?.find((l) => l.level === lv)?.required_count
if (!req || g < req) break
g -= req
lv += 1
}
return lv
}, [equipped, isMax, level, growth, symbol.levels, symbol.max_level])
// 남은 일수/예상 완료일
const { days: daysLeft, date: completeDate } = useMemo(() => {
if (!equipped || isMax) return { days: null, date: null }
return computeCompletion({
remainingSymbols,
daily,
weeklyPerWeek: (weeklyCount || 0) * (symbol.weekly_default || 0),
extra,
dailyDone,
})
}, [equipped, isMax, remainingSymbols, daily, weeklyCount, symbol.weekly_default, extra, dailyDone])
return (
{symbol.image_url && (

)}
{symbol.region}
Lv.{level}
/ {symbol.max_level}
{equipped && !isMax && (
)}
{/* 진행도 바 */}
{reachableLevel > level ? (
성장치 {growth} / {requireGrowth}
) : (
성장치 {isMax ? (
MAX
) : (
<>{growth} / {requireGrowth}>
)}
)}
{!isMax && (
{requireGrowth ? Math.min(Math.floor((growth / requireGrowth) * 100), 100) : 0}%
)}
{/* 획득량 입력 */}
0 ? '0.7fr 1.3fr 1fr' : '1fr 1fr' }}
>
patch({ daily: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })}
disabled={!interactable}
className="w-full h-10 rounded-md border border-white/10 bg-gray-950 px-3 text-base text-right tabular-nums outline-none focus:border-emerald-500/50 hover:border-white/20 disabled:opacity-50 transition"
/>
{symbol.weekly_default > 0 && (
)}
patch({ extra: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })}
disabled={!interactable}
className="w-full h-10 rounded-md border border-white/10 bg-gray-950 px-3 text-base text-right tabular-nums outline-none focus:border-emerald-500/50 hover:border-white/20 disabled:opacity-50 transition"
/>
{/* 정보 */}
남은 심볼
{equipped && !isMax ? `${remainingSymbols.toLocaleString()}개` : '-'}
필요 메소
{equipped && !isMax ? (
{remainingMeso.toLocaleString()}
) : (
-
)}
체납 메소
{equipped && !isMax ? (
{arrearMeso.toLocaleString()}
) : (
-
)}
남은 일수
{equipped && !isMax && daysLeft != null ? `${daysLeft.toLocaleString()}일` : '-'}
예상 완료일
{equipped && !isMax && completeDate ? formatKoreanDate(completeDate) : '-'}
)
}
export default function Symbol() {
const { setFullscreen } = useLayout()
useEffect(() => {
setFullscreen(true)
return () => setFullscreen(false)
}, [setFullscreen])
// 심볼 목록 (DB에서 로드)
const { data: allSymbols = [] } = useQuery({
queryKey: ['symbol', 'symbols'],
queryFn: () => api('/api/symbols').catch(() => []),
staleTime: 5 * 60 * 1000,
})
const tabs = useMemo(() => {
const groups = {}
for (const s of allSymbols) {
if (!groups[s.type]) groups[s.type] = s
}
return TYPE_ORDER
.filter((t) => groups[t])
.map((t) => ({ key: t, label: `${t} 심볼`, image_url: groups[t].image_url }))
}, [allSymbols])
const characters = useSymbolStore((s) => s.characters)
const selectedCharId = useSymbolStore((s) => s.selectedCharId)
const addCharacter = useSymbolStore((s) => s.addCharacter)
const removeCharacter = useSymbolStore((s) => s.removeCharacter)
const selectCharacter = useSymbolStore((s) => s.selectCharacter)
const syncCharacterSymbols = useSymbolStore((s) => s.syncCharacterSymbols)
const storedTab = useSymbolStore((s) => s.selectedTabs?.[selectedCharId])
const setTabStore = useSymbolStore((s) => s.setTab)
const tab = storedTab || tabs[0]?.key || null
const setTab = (t) => { if (selectedCharId) setTabStore(selectedCharId, t) }
// 각 캐릭터의 장착 심볼 fetch (새로고침마다 갱신)
const symbolQueries = useQueries({
queries: characters.map((c) => ({
queryKey: ['character', 'symbols', c.id],
queryFn: () => api(`/api/character/symbols?ocid=${c.id}`),
enabled: !!c.id,
refetchOnMount: 'always',
staleTime: 0,
})),
})
// symbolQueries 결과를 store로 반영
useEffect(() => {
if (!allSymbols.length || !characters.length) return
// (type, region) → symbol id 매핑
const lookup = {}
for (const s of allSymbols) lookup[`${s.type}|${s.region}`] = s
characters.forEach((c, idx) => {
const q = symbolQueries[idx]
if (!q?.data?.symbols) return
const equippedMap = {}
for (const es of q.data.symbols) {
const match = lookup[`${es.type}|${es.region}`]
if (!match) continue
equippedMap[match.id] = {
level: es.level,
growth: es.growth_count,
require_growth: es.require_growth_count,
}
}
syncCharacterSymbols(c.id, equippedMap)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allSymbols, symbolQueries.map((q) => q.dataUpdatedAt).join(',')])
const [addName, setAddName] = useState('')
const [addError, setAddError] = useState('')
const symbols = allSymbols.filter((s) => s.type === tab)
const tabInfo = tabs.find((t) => t.key === tab)
const searchMutation = useMutation({
mutationFn: (name) => api(`/api/character/search?name=${encodeURIComponent(name)}`),
onSuccess: (data) => {
if (characters.find((c) => c.character_name === data.character_name)) {
setAddError('이미 추가된 캐릭터입니다')
return
}
setAddError('')
setAddName('')
addCharacter(data)
},
onError: (err) => setAddError(err.message || '조회 실패'),
})
const handleSearch = (e) => {
e.preventDefault()
const n = addName.trim()
if (!n) return
setAddError('')
searchMutation.mutate(n)
}
const progress = useSymbolStore((s) => s.progress[selectedCharId])
const isEquipped = (symbolId) => !!progress?.[symbolId]?.equipped
// 현재 탭의 누적 메소 + 최종 완료일 계산
const { totalRequiredMeso, totalArrearMeso, overallDate } = useMemo(() => {
let req = 0, arr = 0, latest = null
for (const s of symbols) {
const p = progress?.[s.id]
if (!p?.equipped) continue
if (p.level >= s.max_level) continue
let remaining = 0
for (const l of s.levels || []) {
if (l.level < p.level) continue
if (l.level === p.level) {
req += l.meso_cost
if ((p.growth || 0) >= l.required_count) arr += l.meso_cost
remaining += Math.max(l.required_count - (p.growth || 0), 0)
} else {
req += l.meso_cost
remaining += l.required_count
}
}
const { date } = computeCompletion({
remainingSymbols: remaining,
daily: p.daily ?? s.daily_default ?? 0,
weeklyPerWeek: (p.weeklyCount ?? 3) * (s.weekly_default || 0),
extra: p.extra || 0,
dailyDone: !!p.dailyDone,
})
if (date && (!latest || date > latest)) latest = date
}
return { totalRequiredMeso: req, totalArrearMeso: arr, overallDate: latest }
}, [symbols, progress])
return (
{/* 캐릭터 조회 */}
{addError &&
{addError}
}
{/* 캐릭터 목록 */}
{characters.length > 0 && (
{characters.map((c) => (
selectCharacter(c.id)}
onRemove={() => removeCharacter(c.id)}
/>
))}
)}
{/* 심볼 타입 탭 */}
{tabs.map((t) => (
))}
{/* 심볼 카드 그리드 */}
{symbols.map((s) => (
))}
{/* 전체 요약 */}
{tabInfo?.label} 전체 만렙 완료 예상일
{overallDate ? formatKoreanDate(overallDate) : '-'}
누적 체납 메소
{totalArrearMeso.toLocaleString()}
남은 필요 메소
{totalRequiredMeso.toLocaleString()}
)
}