주차별 계산 탭 - 주차 추가/삭제/편집 + 영속화

- WeeklyScheduler 컴포넌트: 주차 카드 리스트, 펼침 애니메이션, 추가/삭제
- 주차 추가 시 직전 주차 설정 복사 (done 상태는 초기화)
- 마지막 한 주차는 삭제 불가
- 주차별 날짜 범위 표시 (1주차는 시작 날짜부터 다음 목요일 전일)
- 검은 마법사 월별 슬롯 배정: 한 달에 한 주차만 선점 가능, 두 달 걸치는 주차는 빈 슬롯 활용
- 새 주차 추가 시 같은 달 중복이면 검은 마법사 자동 초기화
- 1주차에만 완료/미완료 버튼 노출
- Select 드롭다운을 portal로 이동해 부모 overflow:hidden 영향 제거
- state.schedulerWeeks로 슬롯별 영속화 + 마이그레이션

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-14 18:58:42 +09:00
parent d1ca41ed4a
commit ef8f7d5ea4
4 changed files with 387 additions and 57 deletions

View file

@ -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'
/**
* 커스텀 드롭다운 셀렉트
* <Select value={x} onChange={...} options={[{value, label}]} />
* 커스텀 드롭다운 셀렉트 (포털로 렌더링 부모 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 = (
<AnimatePresence>
{open && (
<motion.div
ref={popupRef}
initial={{ opacity: 0, y: flipUp ? 6 : -6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: flipUp ? 6 : -6, scale: 0.98 }}
transition={{ duration: 0.15 }}
className={`fixed z-[100] rounded-lg border border-white/10 bg-gray-900 text-white shadow-xl overflow-hidden ${
flipUp ? 'origin-bottom' : 'origin-top'
}`}
style={
flipUp
? { bottom: pos.bottomOffset + 4, left: pos.left, minWidth: pos.width }
: { top: pos.top + 4, left: pos.left, minWidth: pos.width }
}
>
<div className="max-h-60 overflow-y-auto py-1">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => { onChange(opt.value); setOpen(false) }}
className={`w-full text-left px-3 py-2.5 text-sm transition flex items-center gap-2 ${
opt.value === value
? 'bg-emerald-500/10 text-emerald-300'
: 'hover:bg-white/5'
}`}
>
{opt.value === value && (
<svg className="w-3 h-3 shrink-0" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6L5 8.5L9.5 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
<span className={opt.value !== value ? 'pl-5' : ''}>{opt.label}</span>
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
)
return (
<div ref={ref} className={`relative ${className}`}>
<div className={`relative ${className}`}>
<button
ref={buttonRef}
type="button"
@ -49,42 +114,7 @@ export default function Select({ value, onChange, options, disabled, className =
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: flipUp ? 6 : -6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: flipUp ? 6 : -6, scale: 0.98 }}
transition={{ duration: 0.15 }}
className={`absolute z-20 min-w-full rounded-lg border border-white/10 bg-gray-900 shadow-xl overflow-hidden ${
flipUp ? 'bottom-full mb-1 origin-bottom' : 'top-full mt-1 origin-top'
} ${align === 'right' ? 'right-0' : 'left-0'}`}
>
<div className="max-h-60 overflow-y-auto py-1">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => { onChange(opt.value); setOpen(false) }}
className={`w-full text-left px-3 py-2.5 text-sm transition flex items-center gap-2 ${
opt.value === value
? 'bg-emerald-500/10 text-emerald-300'
: 'hover:bg-white/5'
}`}
>
{opt.value === value && (
<svg className="w-3 h-3 shrink-0" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6L5 8.5L9.5 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
<span className={opt.value !== value ? 'pl-5' : ''}>{opt.label}</span>
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{createPortal(popup, document.body)}
</div>
)
}

View file

@ -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 }))}
/>
<div className="max-w-3xl mx-auto flex justify-end">

View file

@ -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
))}
</div>
) : (
<WeeklyDesignMocks />
<WeeklyScheduler
startDate={startDate}
weeks={weeks}
onChangeWeeks={onChangeWeeks}
/>
)}
</div>
)

View file

@ -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 (
<div className="flex flex-col items-center gap-1">
<div
className={`rounded-md overflow-hidden bg-gray-900 border border-white/5 ${enabled ? '' : 'opacity-30 grayscale'}`}
style={{ width: size, height: size }}
>
<img src={`${LIBERATION_BOSS_IMAGE_BASE}/${boss.image}`} alt={boss.name} className="w-full h-full object-cover" />
</div>
<div
className="text-[10px] font-bold leading-none rounded flex items-center justify-center border"
style={{
width: 16, height: 16,
color: badge?.color || '#4b5563',
background: badge?.bg || 'transparent',
borderColor: badge?.border || 'rgba(255,255,255,0.08)',
}}
>
{badge?.label || '-'}
</div>
</div>
)
}
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 (
<div className="divide-y divide-white/5">
{WEEKLY_BOSSES.map((boss) => (
<BossRow
key={boss.key}
boss={boss}
sel={config.bosses[boss.key]}
onChange={(patch) => updateBoss(boss.key, patch)}
showDone={isCurrent}
/>
))}
<div className={blackmageLocked ? 'opacity-40 pointer-events-none' : ''}>
<BossRow
boss={MONTHLY_BOSSES[0]}
sel={blackmageLocked ? { difficulty: 'none', party: 1, done: false } : config.blackMage}
onChange={updateBlackMage}
monthly
showDone={isCurrent}
/>
</div>
{blackmageLocked && (
<div className="text-[11px] text-amber-400/80 px-3 py-2">
이번 검은 마법사는 {monthlyLockedByWeek}주차에 배정되어 있습니다.
</div>
)}
</div>
)
}
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 (
<div className="space-y-2">
{weeks.map((w, idx) => {
const n = idx + 1
const isOpen = expanded === w.id
const isCurrent = idx === 0 // : ( )
// monthlyLocks
const monthlyLockedByWeek = monthlyLocks[idx] ?? null
return (
<div
key={w.id}
className="rounded-xl border border-white/5 bg-gray-950/30"
>
<div className="flex items-center gap-3 pl-4 pr-2 py-3">
<button
type="button"
onClick={() => setExpanded(isOpen ? null : w.id)}
className="flex items-center gap-4 flex-1 text-left hover:opacity-90 transition"
>
<div className="w-12 text-center shrink-0">
<div className="text-[11px] text-gray-500 leading-tight">주차</div>
<div className="text-xl font-extrabold tabular-nums leading-tight text-gray-200">{n}</div>
</div>
{startDate && (
<div className="text-sm text-gray-400 tabular-nums w-24 shrink-0">
{formatRange(getWeekRange(startDate, n))}
</div>
)}
<div className="flex-1 flex items-center gap-2">
{WEEKLY_BOSSES.map((b) => (
<BossAvatar key={b.key} boss={b} difficulty={w.config.bosses[b.key]?.difficulty} size={40} />
))}
<BossAvatar boss={MONTHLY_BOSSES[0]} difficulty={w.config.blackMage?.difficulty} size={40} />
</div>
<svg
width="16" height="16" viewBox="0 0 12 12" fill="none"
className={`text-gray-500 transition-transform shrink-0 ${isOpen ? 'rotate-180' : ''}`}
>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
onClick={() => removeWeek(w.id)}
disabled={weeks.length <= 1}
title={weeks.length <= 1 ? '최소 한 주차는 유지되어야 합니다' : '이 주차 삭제'}
className="shrink-0 w-8 h-8 rounded-md text-gray-500 hover:text-red-400 hover:bg-red-500/10 disabled:opacity-30 disabled:hover:text-gray-500 disabled:hover:bg-transparent disabled:cursor-not-allowed transition flex items-center justify-center"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M1 1L13 13M13 1L1 13" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
</div>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
key="editor"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: { duration: 0.35, ease: [0.22, 1, 0.36, 1] },
opacity: { duration: 0.25, ease: [0.22, 1, 0.36, 1] },
}}
style={{ overflow: 'hidden' }}
>
<div className="border-t border-white/5 px-3 py-3 bg-gray-950/40">
<WeekEditor
config={w.config}
onChange={(c) => updateWeek(w.id, c)}
isCurrent={isCurrent}
monthlyLockedByWeek={monthlyLockedByWeek}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
<button
type="button"
onClick={addWeek}
className="w-full rounded-xl border border-dashed border-white/10 hover:border-emerald-500/40 hover:bg-emerald-500/5 text-gray-500 hover:text-emerald-300 py-3 text-sm font-semibold transition flex items-center justify-center gap-2"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 1V13M1 7H13" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
주차 추가
</button>
</div>
)
}