diff --git a/frontend/src/components/Select.jsx b/frontend/src/components/Select.jsx
index 13013e3..bf4d3d1 100644
--- a/frontend/src/components/Select.jsx
+++ b/frontend/src/components/Select.jsx
@@ -1,38 +1,103 @@
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
import { motion, AnimatePresence } from 'framer-motion'
/**
- * 커스텀 드롭다운 셀렉트
- *
+ * 커스텀 드롭다운 셀렉트 (포털로 렌더링 → 부모 overflow:hidden에도 잘림 없음)
*/
export default function Select({ value, onChange, options, disabled, className = '', placeholder = '선택', align = 'left' }) {
const [open, setOpen] = useState(false)
const [flipUp, setFlipUp] = useState(false)
- const ref = useRef(null)
+ const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
const buttonRef = useRef(null)
+ const popupRef = useRef(null)
- useEffect(() => {
- if (!open) return
- const handler = (e) => {
- if (!ref.current?.contains(e.target)) setOpen(false)
- }
- document.addEventListener('mousedown', handler)
- return () => document.removeEventListener('mousedown', handler)
- }, [open])
-
- useEffect(() => {
- if (!open || !buttonRef.current) return
+ const updatePosition = () => {
+ if (!buttonRef.current) return
const rect = buttonRef.current.getBoundingClientRect()
const estHeight = Math.min(options.length * 44 + 8, 240)
const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top
- setFlipUp(spaceBelow < estHeight && spaceAbove > spaceBelow)
- }, [open, options.length])
+ const flip = spaceBelow < estHeight && spaceAbove > spaceBelow
+ setFlipUp(flip)
+ setPos({
+ top: flip ? rect.top : rect.bottom,
+ left: rect.left,
+ width: rect.width,
+ bottomOffset: flip ? window.innerHeight - rect.top : 0,
+ })
+ }
+
+ useLayoutEffect(() => {
+ if (open) updatePosition()
+ }, [open])
+
+ useEffect(() => {
+ if (!open) return
+ const onDown = (e) => {
+ if (buttonRef.current?.contains(e.target)) return
+ if (popupRef.current?.contains(e.target)) return
+ setOpen(false)
+ }
+ const onScroll = () => updatePosition()
+ document.addEventListener('mousedown', onDown)
+ window.addEventListener('scroll', onScroll, true)
+ window.addEventListener('resize', onScroll)
+ return () => {
+ document.removeEventListener('mousedown', onDown)
+ window.removeEventListener('scroll', onScroll, true)
+ window.removeEventListener('resize', onScroll)
+ }
+ }, [open])
const selected = options.find((o) => o.value === value)
+ const popup = (
+
+ {open && (
+
+
+ {options.map((opt) => (
+
+ ))}
+
+
+ )}
+
+ )
+
return (
-
+
-
-
- {open && (
-
-
- {options.map((opt) => (
-
- ))}
-
-
- )}
-
+ {createPortal(popup, document.body)}
)
}
diff --git a/frontend/src/features/liberation/Liberation.jsx b/frontend/src/features/liberation/Liberation.jsx
index 425cbe6..8d2086e 100644
--- a/frontend/src/features/liberation/Liberation.jsx
+++ b/frontend/src/features/liberation/Liberation.jsx
@@ -100,6 +100,7 @@ export default function Liberation() {
weekly: makeEmptyWeekly(),
weekOverrides: {},
weeks: [makeEmptyWeek(todayKST())],
+ schedulerWeeks: [{ id: 1, config: makeEmptyWeekly() }],
})
const [root, setRoot] = useState(() => {
@@ -112,8 +113,15 @@ export default function Liberation() {
if (!parsed.weekly) parsed.weekly = makeEmptyWeekly()
if (!parsed.startDate) parsed.startDate = dayjs(todayKST()).toISOString()
if (!parsed.weekOverrides) parsed.weekOverrides = {}
+ if (!parsed.schedulerWeeks) parsed.schedulerWeeks = [{ id: 1, config: makeEmptyWeekly() }]
return { calcMode: 'simple', simple: parsed, weekly: makeInitialSlot() }
}
+ // 새 구조에서 schedulerWeeks 누락 시 채움
+ ;['simple', 'weekly'].forEach((k) => {
+ if (parsed[k] && !parsed[k].schedulerWeeks) {
+ parsed[k].schedulerWeeks = [{ id: 1, config: makeEmptyWeekly() }]
+ }
+ })
return parsed
} catch { /* ignore */ }
}
@@ -377,6 +385,9 @@ export default function Liberation() {
totalWeekly={weeklyEarn}
totalMonthly={monthlyEarn}
mode={calcMode}
+ startDate={state.startDate}
+ weeks={state.schedulerWeeks}
+ onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
/>
diff --git a/frontend/src/features/liberation/components/WeeklyDefault.jsx b/frontend/src/features/liberation/components/WeeklyDefault.jsx
index d3fccb6..d9b411a 100644
--- a/frontend/src/features/liberation/components/WeeklyDefault.jsx
+++ b/frontend/src/features/liberation/components/WeeklyDefault.jsx
@@ -1,7 +1,7 @@
import { useState } from 'react'
import Select from '../../../components/Select'
import Tooltip from '../../../components/Tooltip'
-import WeeklyDesignMocks from './WeeklyDesignMocks'
+import WeeklyScheduler from './WeeklyScheduler'
import { WEEKLY_BOSSES, MONTHLY_BOSSES, LIBERATION_BOSS_IMAGE_BASE, calcPoints } from '../data'
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}인` }))
@@ -70,7 +70,7 @@ export function BossRow({ boss, sel, onChange, monthly = false, showDone = true
)
}
-export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMonthly, mode = 'simple' }) {
+export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMonthly, mode = 'simple', startDate, weeks, onChangeWeeks }) {
const updateBoss = (key, patch) => {
onChange({ ...weekly, bosses: { ...weekly.bosses, [key]: { ...weekly.bosses[key], ...patch } } })
}
@@ -113,7 +113,11 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
))}
) : (
-
+
)}
)
diff --git a/frontend/src/features/liberation/components/WeeklyScheduler.jsx b/frontend/src/features/liberation/components/WeeklyScheduler.jsx
new file mode 100644
index 0000000..ea72d6c
--- /dev/null
+++ b/frontend/src/features/liberation/components/WeeklyScheduler.jsx
@@ -0,0 +1,285 @@
+import { useState } from 'react'
+import { motion, AnimatePresence } from 'framer-motion'
+import dayjs from 'dayjs'
+import { LIBERATION_BOSS_IMAGE_BASE, WEEKLY_BOSSES, MONTHLY_BOSSES } from '../data'
+import { BossRow } from './WeeklyDefault'
+
+const KST = 'Asia/Seoul'
+
+// 주차별 날짜 범위 계산 (목요일 리셋, 1주차는 시작일부터 다음 목요일 전까지)
+function getWeekRange(startDateStr, weekIdx) {
+ const start = dayjs(startDateStr).tz(KST).startOf('day')
+ const dow = start.day()
+ const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
+ const nextThu = start.add(daysToNextThu, 'day')
+ if (weekIdx === 1) {
+ return { start, end: nextThu.subtract(1, 'day') }
+ }
+ const weekStart = nextThu.add((weekIdx - 2) * 7, 'day')
+ const weekEnd = weekStart.add(6, 'day')
+ return { start: weekStart, end: weekEnd }
+}
+
+function formatRange(r) {
+ const fmt = (d) => `${d.month() + 1}/${d.date()}`
+ return `${fmt(r.start)} ~ ${fmt(r.end)}`
+}
+
+const DIFF_BADGE = {
+ easy: { label: 'E', color: '#22c55e', border: 'rgba(34,197,94,0.4)', bg: 'rgba(34,197,94,0.15)' },
+ normal: { label: 'N', color: '#60a5fa', border: 'rgba(96,165,250,0.4)', bg: 'rgba(96,165,250,0.15)' },
+ hard: { label: 'H', color: '#f87171', border: 'rgba(248,113,113,0.4)', bg: 'rgba(248,113,113,0.15)' },
+ chaos: { label: 'C', color: '#c084fc', border: 'rgba(192,132,252,0.45)', bg: 'rgba(192,132,252,0.15)' },
+ extreme: { label: 'X', color: '#f59e0b', border: 'rgba(245,158,11,0.5)', bg: 'rgba(245,158,11,0.2)' },
+}
+
+function makeEmptyWeek() {
+ const bosses = {}
+ WEEKLY_BOSSES.forEach((b) => {
+ bosses[b.key] = { difficulty: 'none', party: 1, done: false }
+ })
+ return {
+ bosses,
+ blackMage: { difficulty: 'none', party: 1, done: false },
+ }
+}
+
+function BossAvatar({ boss, difficulty, size = 40 }) {
+ const badge = DIFF_BADGE[difficulty]
+ const enabled = difficulty && difficulty !== 'none'
+ return (
+
+
+

+
+
+ {badge?.label || '-'}
+
+
+ )
+}
+
+function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
+ const updateBoss = (key, patch) => {
+ onChange({ ...config, bosses: { ...config.bosses, [key]: { ...config.bosses[key], ...patch } } })
+ }
+ const updateBlackMage = (patch) => {
+ onChange({ ...config, blackMage: { ...config.blackMage, ...patch } })
+ }
+
+ const blackmageLocked = monthlyLockedByWeek != null
+
+ return (
+
+ {WEEKLY_BOSSES.map((boss) => (
+
updateBoss(boss.key, patch)}
+ showDone={isCurrent}
+ />
+ ))}
+
+
+
+ {blackmageLocked && (
+
+ 이번 달 검은 마법사는 {monthlyLockedByWeek}주차에 배정되어 있습니다.
+
+ )}
+
+ )
+}
+
+export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeWeeks }) {
+ const weeks = weeksProp && weeksProp.length > 0
+ ? weeksProp
+ : [{ id: 1, config: makeEmptyWeek() }]
+ const setWeeks = (updater) => {
+ const next = typeof updater === 'function' ? updater(weeks) : updater
+ onChangeWeeks?.(next)
+ }
+ const [expanded, setExpanded] = useState(null)
+ const nextId = () => (weeks[weeks.length - 1]?.id ?? 0) + 1
+
+ const addWeek = () => {
+ const id = nextId()
+ setWeeks((prev) => {
+ const last = prev[prev.length - 1]
+ const base = last ? JSON.parse(JSON.stringify(last.config)) : makeEmptyWeek()
+ // done 상태는 복사하지 않음
+ Object.keys(base.bosses).forEach((k) => { base.bosses[k].done = false })
+ if (base.blackMage) base.blackMage.done = false
+
+ // 새 주차의 달에 이미 검은 마법사가 배정되어 있으면 복사된 검은마법사는 초기화
+ if (startDate && base.blackMage?.difficulty && base.blackMage.difficulty !== 'none') {
+ const newIdx = prev.length + 1
+ const newMonth = getWeekRange(startDate, newIdx).start.format('YYYY-MM')
+ const existsInSameMonth = prev.some((p, i) => {
+ if (!p.config.blackMage?.difficulty || p.config.blackMage.difficulty === 'none') return false
+ return getWeekRange(startDate, i + 1).start.format('YYYY-MM') === newMonth
+ })
+ if (existsInSameMonth) {
+ base.blackMage = { difficulty: 'none', party: 1, done: false }
+ }
+ }
+
+ return [...prev, { id, config: base }]
+ })
+ setExpanded(id)
+ }
+
+ const removeWeek = (id) => {
+ setWeeks((prev) => prev.filter((w) => w.id !== id))
+ if (expanded === id) setExpanded(null)
+ }
+
+ const updateWeek = (id, config) => {
+ setWeeks((prev) => prev.map((w) => (w.id === id ? { ...w, config } : w)))
+ }
+
+ // 검은 마법사 월별 슬롯 배정: 각 주차가 겹치는 달 중 하나를 선점
+ const monthlyLocks = (() => {
+ if (!startDate) return {}
+ const claimed = {} // month -> weekNum (1-based)
+ weeks.forEach((w, idx) => {
+ const diff = w.config.blackMage?.difficulty
+ if (!diff || diff === 'none') return
+ const r = getWeekRange(startDate, idx + 1)
+ const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
+ for (const m of months) {
+ if (!(m in claimed)) {
+ claimed[m] = idx + 1
+ return
+ }
+ }
+ })
+ const locks = {}
+ weeks.forEach((w, idx) => {
+ const r = getWeekRange(startDate, idx + 1)
+ const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
+ // 본인이 한 달이라도 차지했으면 잠그지 않음
+ if (months.some((m) => claimed[m] === idx + 1)) return
+ // 겹치는 달이 모두 다른 주차에 점유되었으면 잠금
+ if (months.every((m) => m in claimed)) {
+ locks[idx] = claimed[months[0]] ?? claimed[months[1]]
+ }
+ })
+ return locks
+ })()
+
+ return (
+
+ {weeks.map((w, idx) => {
+ const n = idx + 1
+ const isOpen = expanded === w.id
+ const isCurrent = idx === 0 // 임시: 첫 번째가 현재 주차 (실제 연결 시 날짜 기반)
+ // 검은마법사 잠금 판정은 아래 사전 계산된 monthlyLocks 사용
+ const monthlyLockedByWeek = monthlyLocks[idx] ?? null
+ return (
+
+
+
+
+
+
+
+ {isOpen && (
+
+
+ updateWeek(w.id, c)}
+ isCurrent={isCurrent}
+ monthlyLockedByWeek={monthlyLockedByWeek}
+ />
+
+
+ )}
+
+
+ )
+ })}
+
+
+
+ )
+}