주차별 계산 탭 - 주차 추가/삭제/편집 + 영속화
- 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'
|
||||
|
||||
/**
|
||||
* 커스텀 드롭다운 셀렉트
|
||||
* <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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
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