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" style={{ borderColor: active ? 'var(--selected-border)' : 'var(--panel-border)', background: active ? 'var(--selected-bg)' : 'var(--surface-3)', }} >
{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 { remainingSymbols, remainingMeso, arrearMeso } = useMemo(() => { if (!equipped || !symbol.levels?.length) return { remainingSymbols: 0, remainingMeso: 0, arrearMeso: 0 } let sym = 0, meso = 0, arr = 0 // 체납: 현재 성장치로 올릴 수 있는 레벨까지 누적 let arrLv = level, arrG = growth while (arrLv < symbol.max_level) { const req = symbol.levels.find((l) => l.level === arrLv)?.required_count const cost = symbol.levels.find((l) => l.level === arrLv)?.meso_cost if (req == null || cost == null || arrG < req) break arr += cost arrG -= req arrLv += 1 } let g = growth for (const l of symbol.levels) { if (l.level < level) continue sym += Math.max(l.required_count - g, 0) g = Math.max(g - l.required_count, 0) meso += l.meso_cost } return { remainingSymbols: sym, remainingMeso: meso, arrearMeso: arr } }, [equipped, level, growth, symbol.levels, symbol.max_level]) // 현재 성장치로 도달 가능한 최대 레벨 (연속 체납 반영) 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 effectivelyMax = equipped && !isMax && reachableLevel >= symbol.max_level const interactable = equipped && !isMax && !effectivelyMax // 남은 일수/예상 완료일 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]) const inputClass = "w-full h-10 rounded-md border px-3 text-base text-right tabular-nums outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)] disabled:opacity-50" return (
{symbol.image_url && ( {symbol.region} )}
{symbol.region}
Lv.{level} / {symbol.max_level}
{equipped && !isMax && !effectivelyMax && ( )}
{/* 진행도 바 */}
{isMax ? ( 성장치 MAX ) : effectivelyMax ? ( 성장치 {growth} (MAX) / {requireGrowth} ) : reachableLevel > level ? ( 성장치 {growth} / {requireGrowth} ) : ( 성장치 {growth} / {requireGrowth} )} {!isMax && !effectivelyMax && ( {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={inputClass} style={{ background: 'var(--input-bg)', borderColor: 'var(--input-border)', color: 'var(--text-strong)', }} />
{symbol.weekly_default > 0 && (
patch({ extra: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })} disabled={!interactable} className={inputClass} style={{ background: 'var(--input-bg)', borderColor: 'var(--input-border)', color: 'var(--text-strong)', }} />
{/* 정보 */}
{[ { label: '남은 심볼', value: equipped && !isMax && !effectivelyMax ? `${remainingSymbols.toLocaleString()}개` : '-', color: 'var(--text-emphasis)' }, { label: '필요 메소', value: equipped && !isMax ? remainingMeso.toLocaleString() : '-', color: 'var(--warning-text-bright)', tooltip: equipped && !isMax ? formatMesoKorean(remainingMeso) : null }, { label: '체납 메소', value: equipped && !isMax ? arrearMeso.toLocaleString() : '-', color: 'var(--danger-text)', tooltip: equipped && !isMax ? formatMesoKorean(arrearMeso) : null }, { label: '남은 일수', value: equipped && !isMax && !effectivelyMax && daysLeft != null ? `${daysLeft.toLocaleString()}일` : '-', color: 'var(--text-emphasis)' }, { label: '예상 완료일', value: equipped && !isMax && !effectivelyMax && completeDate ? formatKoreanDate(completeDate) : '-', color: equipped && !isMax && !effectivelyMax && completeDate ? 'var(--accent-bright)' : 'var(--text-dim)', strong: true }, ].map((row, i) => (
{row.label} {row.tooltip ? ( {row.value} ) : ( {row.value} )}
))}
) } 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 updateCharacter = useSymbolStore((s) => s.updateCharacter) 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) } // 각 캐릭터 기본정보(코디 이미지) 새로고침 const basicQueries = useQueries({ queries: characters.map((c) => ({ queryKey: ['character', 'basic', c.character_name], queryFn: () => api(`/api/character/search?name=${encodeURIComponent(c.character_name)}`), enabled: !!c.character_name, refetchOnMount: 'always', staleTime: 0, retry: false, })), }) useEffect(() => { characters.forEach((c, idx) => { const d = basicQueries[idx]?.data if (!d) return if (d.character_image !== c.character_image || d.character_level !== c.character_level || d.job_name !== c.job_name) { updateCharacter(c.id, { character_image: d.character_image, character_level: d.character_level, job_name: d.job_name, world_name: d.world_name, }) } }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [basicQueries.map((q) => q.dataUpdatedAt).join(',')]) // 각 캐릭터의 장착 심볼 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 lv = p.level, g = p.growth || 0 while (lv < s.max_level) { const r = s.levels?.find((l) => l.level === lv)?.required_count if (!r || g < r) break g -= r; lv += 1 } const effMax = lv >= s.max_level // 체납 누적 (성장치 cascade) let arrLv = p.level, arrG = p.growth || 0 while (arrLv < s.max_level) { const lv = s.levels?.find((x) => x.level === arrLv) if (!lv || arrG < lv.required_count) break arr += lv.meso_cost arrG -= lv.required_count arrLv += 1 } let remaining = 0 let gg = p.growth || 0 for (const l of s.levels || []) { if (l.level < p.level) continue remaining += Math.max(l.required_count - gg, 0) gg = Math.max(gg - l.required_count, 0) req += l.meso_cost } if (effMax) continue // 완료 예상일 계산에서 제외 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 (
{/* 캐릭터 조회 */}
{ setAddName(e.target.value); if (addError) setAddError('') }} placeholder="캐릭터 닉네임으로 장착 심볼 불러오기" className="w-full h-12 box-border rounded-lg border pl-10 pr-4 text-base 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)', }} />
{addError && (

{addError}

)} {/* 캐릭터 목록 */} {characters.length > 0 && (
{characters.map((c) => ( selectCharacter(c.id)} onRemove={() => removeCharacter(c.id)} /> ))}
)}
{/* 심볼 타입 탭 */}
{tabs.map((t) => { const active = tab === t.key return ( ) })}
{/* 심볼 카드 그리드 */}
{symbols.map((s) => ( ))}
{/* 전체 요약 */}
{tabInfo?.label} 전체 만렙 완료 예상일
{overallDate ? formatKoreanDate(overallDate) : '-'}
누적 체납 메소
{totalArrearMeso.toLocaleString()}
남은 필요 메소
{totalRequiredMeso.toLocaleString()}
) }