주차별 계산 탭 - 주차 추가/삭제/편집 + 영속화
- 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:
parent
d1ca41ed4a
commit
ef8f7d5ea4
4 changed files with 387 additions and 57 deletions
|
|
@ -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'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 커스텀 드롭다운 셀렉트
|
* 커스텀 드롭다운 셀렉트 (포털로 렌더링 → 부모 overflow:hidden에도 잘림 없음)
|
||||||
* <Select value={x} onChange={...} options={[{value, label}]} />
|
|
||||||
*/
|
*/
|
||||||
export default function Select({ value, onChange, options, disabled, className = '', placeholder = '선택', align = 'left' }) {
|
export default function Select({ value, onChange, options, disabled, className = '', placeholder = '선택', align = 'left' }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [flipUp, setFlipUp] = 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 buttonRef = useRef(null)
|
||||||
|
const popupRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
const updatePosition = () => {
|
||||||
if (!open) return
|
if (!buttonRef.current) 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 rect = buttonRef.current.getBoundingClientRect()
|
const rect = buttonRef.current.getBoundingClientRect()
|
||||||
const estHeight = Math.min(options.length * 44 + 8, 240)
|
const estHeight = Math.min(options.length * 44 + 8, 240)
|
||||||
const spaceBelow = window.innerHeight - rect.bottom
|
const spaceBelow = window.innerHeight - rect.bottom
|
||||||
const spaceAbove = rect.top
|
const spaceAbove = rect.top
|
||||||
setFlipUp(spaceBelow < estHeight && spaceAbove > spaceBelow)
|
const flip = spaceBelow < estHeight && spaceAbove > spaceBelow
|
||||||
}, [open, options.length])
|
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 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 (
|
return (
|
||||||
<div ref={ref} className={`relative ${className}`}>
|
<div className={`relative ${className}`}>
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
type="button"
|
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" />
|
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
{createPortal(popup, document.body)}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ export default function Liberation() {
|
||||||
weekly: makeEmptyWeekly(),
|
weekly: makeEmptyWeekly(),
|
||||||
weekOverrides: {},
|
weekOverrides: {},
|
||||||
weeks: [makeEmptyWeek(todayKST())],
|
weeks: [makeEmptyWeek(todayKST())],
|
||||||
|
schedulerWeeks: [{ id: 1, config: makeEmptyWeekly() }],
|
||||||
})
|
})
|
||||||
|
|
||||||
const [root, setRoot] = useState(() => {
|
const [root, setRoot] = useState(() => {
|
||||||
|
|
@ -112,8 +113,15 @@ export default function Liberation() {
|
||||||
if (!parsed.weekly) parsed.weekly = makeEmptyWeekly()
|
if (!parsed.weekly) parsed.weekly = makeEmptyWeekly()
|
||||||
if (!parsed.startDate) parsed.startDate = dayjs(todayKST()).toISOString()
|
if (!parsed.startDate) parsed.startDate = dayjs(todayKST()).toISOString()
|
||||||
if (!parsed.weekOverrides) parsed.weekOverrides = {}
|
if (!parsed.weekOverrides) parsed.weekOverrides = {}
|
||||||
|
if (!parsed.schedulerWeeks) parsed.schedulerWeeks = [{ id: 1, config: makeEmptyWeekly() }]
|
||||||
return { calcMode: 'simple', simple: parsed, weekly: makeInitialSlot() }
|
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
|
return parsed
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
@ -377,6 +385,9 @@ export default function Liberation() {
|
||||||
totalWeekly={weeklyEarn}
|
totalWeekly={weeklyEarn}
|
||||||
totalMonthly={monthlyEarn}
|
totalMonthly={monthlyEarn}
|
||||||
mode={calcMode}
|
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">
|
<div className="max-w-3xl mx-auto flex justify-end">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Select from '../../../components/Select'
|
import Select from '../../../components/Select'
|
||||||
import Tooltip from '../../../components/Tooltip'
|
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'
|
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}인` }))
|
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) => {
|
const updateBoss = (key, patch) => {
|
||||||
onChange({ ...weekly, bosses: { ...weekly.bosses, [key]: { ...weekly.bosses[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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<WeeklyDesignMocks />
|
<WeeklyScheduler
|
||||||
|
startDate={startDate}
|
||||||
|
weeks={weeks}
|
||||||
|
onChangeWeeks={onChangeWeeks}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
285
frontend/src/features/liberation/components/WeeklyScheduler.jsx
Normal file
285
frontend/src/features/liberation/components/WeeklyScheduler.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue