+
{totalRequiredMeso.toLocaleString()}
diff --git a/frontend/src/features/symbol/pc/user/CharacterCard.jsx b/frontend/src/features/symbol/pc/user/CharacterCard.jsx
new file mode 100644
index 0000000..1d14682
--- /dev/null
+++ b/frontend/src/features/symbol/pc/user/CharacterCard.jsx
@@ -0,0 +1,57 @@
+import { memo } from 'react'
+
+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}
+
+
+
+ )
+}
+
+export default memo(CharacterCard)
diff --git a/frontend/src/features/symbol/pc/user/SymbolCard.jsx b/frontend/src/features/symbol/pc/user/SymbolCard.jsx
new file mode 100644
index 0000000..d166bee
--- /dev/null
+++ b/frontend/src/features/symbol/pc/user/SymbolCard.jsx
@@ -0,0 +1,250 @@
+import { memo, useMemo } from 'react'
+import Select from '../../../../components/common/Select'
+import Tooltip from '../../../../components/common/Tooltip'
+import { useSymbolStore } from '../../store'
+import { formatMesoKorean } from '../../../../utils/formatting'
+import { formatKoreanDate, computeCompletion } from '../../utils'
+
+const INPUT_CLASS = "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"
+const INPUT_STYLE = {
+ background: 'var(--input-bg)',
+ borderColor: 'var(--input-border)',
+ color: 'var(--text-strong)',
+}
+
+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])
+
+ return (
+
+
+
+ {symbol.image_url && (
+

+ )}
+
+
+
{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={INPUT_CLASS}
+ style={INPUT_STYLE}
+ />
+
+ {symbol.weekly_default > 0 && (
+
+
+
+ )}
+
+
+ patch({ extra: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })}
+ disabled={!interactable}
+ className={INPUT_CLASS}
+ style={INPUT_STYLE}
+ />
+
+
+
+ {/* 정보 */}
+
+ {[
+ { 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) => (
+
+ {row.label}
+ {row.tooltip ? (
+
+
+ {row.value}
+
+
+ ) : (
+
+ {row.value}
+
+ )}
+
+ ))}
+
+
+ )
+}
+
+export default memo(SymbolCard)
diff --git a/frontend/src/features/symbol/utils.js b/frontend/src/features/symbol/utils.js
new file mode 100644
index 0000000..6eaf3a4
--- /dev/null
+++ b/frontend/src/features/symbol/utils.js
@@ -0,0 +1,38 @@
+import dayjs from 'dayjs'
+import utc from 'dayjs/plugin/utc'
+import timezone from 'dayjs/plugin/timezone'
+
+dayjs.extend(utc)
+dayjs.extend(timezone)
+
+export const KST = 'Asia/Seoul'
+const DOW = ['일', '월', '화', '수', '목', '금', '토']
+
+export 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이면 오늘 일퀘는 이미 받은 걸로 간주 (내일부터 다시 지급)
+ */
+export 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++) {
+ if (!(day === 0 && dailyDone)) acc += daily
+ 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 }
+}
+
+export const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱']