feat: 일정 주간/월간 뷰 전환 추가
상단 토글(주간/월간)로 보기 전환. 월간은 전통 달력 그리드(주×요일). - scheduleDate: firstOfMonth/addMonths/monthLabel/monthGridWeeks(+MonthDay) 추가 - schedule.store: viewMode + anchor 기반으로 주간/월간 범위·라벨·네비 일원화 (goPrev/goNext 가 모드별로 주/월 이동, setViewMode 시 범위 재조회) - ScheduleMonthGrid: 주 단위 다중일 막대(레인 패킹) + 초과 '+N', 카테고리 색·유형 필터·오늘/주말/이전·다음달 표기, 막대 클릭 시 상세 패널 - SchedulePage: SegmentedTabs 토글 + 주간/월간 조건부 렌더 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
<script setup lang="ts">
|
||||
// 월간 달력 그리드 — 주(행) × 요일(열). 일정은 주 단위로 다중일 막대(span)로 배치하고,
|
||||
// 한 주에서 보이는 레인을 초과하면 해당 날짜 칸에 '+N' 으로 표기. 카테고리 색·유형 필터 반영.
|
||||
import { computed } from 'vue'
|
||||
import type { ApiScheduleCategory, ApiScheduleEvent } from '@/types/schedule'
|
||||
import type { MonthDay } from '@/shared/utils/scheduleDate'
|
||||
|
||||
const props = defineProps<{
|
||||
weeks: MonthDay[][]
|
||||
events: ApiScheduleEvent[]
|
||||
categories: ApiScheduleCategory[]
|
||||
activeTypeIds: Set<string>
|
||||
selId: string | null
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'select', id: string): void }>()
|
||||
|
||||
const WEEKDAYS = ['월', '화', '수', '목', '금', '토', '일']
|
||||
// 한 주에서 막대로 보일 최대 레인 수(초과분은 '+N')
|
||||
const VISIBLE_LANES = 3
|
||||
|
||||
const colorMap = computed(() => {
|
||||
const m: Record<string, string> = {}
|
||||
for (const c of props.categories) m[c.id] = c.color
|
||||
return m
|
||||
})
|
||||
const colorOf = (catId: string): string => colorMap.value[catId] ?? '#9298a3'
|
||||
const weakOf = (color: string): string => `color-mix(in srgb, ${color} 13%, #fff)`
|
||||
|
||||
const visibleEvents = computed(() =>
|
||||
props.events.filter((e) => props.activeTypeIds.has(e.categoryId)),
|
||||
)
|
||||
|
||||
interface MBar {
|
||||
ev: ApiScheduleEvent
|
||||
startIdx: number
|
||||
span: number
|
||||
lane: number
|
||||
}
|
||||
interface LaidWeek {
|
||||
week: MonthDay[]
|
||||
items: MBar[]
|
||||
moreByDay: number[]
|
||||
}
|
||||
|
||||
// 주별로 일정을 클립(해당 주 범위)·정렬·레인 패킹
|
||||
const laidWeeks = computed<LaidWeek[]>(() =>
|
||||
props.weeks.map((week) => {
|
||||
const ws = week[0]?.iso ?? ''
|
||||
const we = week[6]?.iso ?? ''
|
||||
const cov = visibleEvents.value
|
||||
.map((ev) => {
|
||||
if (ev.end < ws || ev.start > we) return null
|
||||
let startIdx = -1
|
||||
let endIdx = -1
|
||||
week.forEach((d, i) => {
|
||||
if (d.iso >= ev.start && d.iso <= ev.end) {
|
||||
if (startIdx < 0) startIdx = i
|
||||
endIdx = i
|
||||
}
|
||||
})
|
||||
if (startIdx < 0) return null
|
||||
return { ev, startIdx, endIdx }
|
||||
})
|
||||
.filter((c): c is { ev: ApiScheduleEvent; startIdx: number; endIdx: number } => c !== null)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.startIdx - b.startIdx ||
|
||||
String(a.ev.time).localeCompare(String(b.ev.time)),
|
||||
)
|
||||
|
||||
const rowEnd: number[] = []
|
||||
const items: MBar[] = []
|
||||
const moreByDay = [0, 0, 0, 0, 0, 0, 0]
|
||||
for (const it of cov) {
|
||||
let r = 0
|
||||
while (r < rowEnd.length && (rowEnd[r] ?? -1) >= it.startIdx) r++
|
||||
rowEnd[r] = it.endIdx
|
||||
if (r < VISIBLE_LANES) {
|
||||
items.push({
|
||||
ev: it.ev,
|
||||
startIdx: it.startIdx,
|
||||
span: it.endIdx - it.startIdx + 1,
|
||||
lane: r,
|
||||
})
|
||||
} else {
|
||||
for (let d = it.startIdx; d <= it.endIdx; d++)
|
||||
moreByDay[d] = (moreByDay[d] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
return { week, items, moreByDay }
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mg">
|
||||
<div class="mg-head">
|
||||
<div
|
||||
v-for="(d, i) in WEEKDAYS"
|
||||
:key="d"
|
||||
class="mg-hd"
|
||||
:class="{ weekend: i >= 5 }"
|
||||
>
|
||||
{{ d }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mg-body">
|
||||
<div
|
||||
v-for="(wk, wi) in laidWeeks"
|
||||
:key="wi"
|
||||
class="mg-week"
|
||||
>
|
||||
<!-- 날짜 칸 배경(테두리/오늘/주말/이전·다음달) -->
|
||||
<div
|
||||
v-for="(d, di) in wk.week"
|
||||
:key="'bg' + d.iso"
|
||||
class="mg-bg"
|
||||
:class="{ today: d.isToday, weekend: d.weekend, outside: !d.inMonth }"
|
||||
:style="{ gridColumn: di + 1, gridRow: '1 / -1' }"
|
||||
/>
|
||||
<!-- 날짜 숫자 -->
|
||||
<div
|
||||
v-for="(d, di) in wk.week"
|
||||
:key="'n' + d.iso"
|
||||
class="mg-dnum"
|
||||
:class="{ today: d.isToday, outside: !d.inMonth }"
|
||||
:style="{ gridColumn: di + 1, gridRow: 1 }"
|
||||
>
|
||||
<span class="mg-dn">{{ d.d }}</span>
|
||||
</div>
|
||||
<!-- 일정 막대 -->
|
||||
<button
|
||||
v-for="b in wk.items"
|
||||
:key="b.ev.id"
|
||||
type="button"
|
||||
class="mg-bar"
|
||||
:class="{ sel: b.ev.id === selId }"
|
||||
:style="{
|
||||
gridColumn: `${b.startIdx + 1} / span ${b.span}`,
|
||||
gridRow: b.lane + 2,
|
||||
'--c': colorOf(b.ev.categoryId),
|
||||
'--w': weakOf(colorOf(b.ev.categoryId)),
|
||||
}"
|
||||
@click="emit('select', b.ev.id)"
|
||||
>
|
||||
<span class="mg-bt">{{ b.ev.title }}</span>
|
||||
</button>
|
||||
<!-- 초과(+N) -->
|
||||
<template
|
||||
v-for="(n, di) in wk.moreByDay"
|
||||
:key="'m' + di"
|
||||
>
|
||||
<div
|
||||
v-if="n > 0"
|
||||
class="mg-more"
|
||||
:style="{ gridColumn: di + 1, gridRow: VISIBLE_LANES + 2 }"
|
||||
>
|
||||
+{{ n }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mg {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.mg-head {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mg-hd {
|
||||
padding: 0.5rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.mg-hd:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
.mg-hd.weekend {
|
||||
color: var(--text-3);
|
||||
background: #fafbfc;
|
||||
}
|
||||
.mg-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mg-week {
|
||||
flex: 1;
|
||||
min-height: 6rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-rows: 1.625rem repeat(3, 1.375rem) 1fr;
|
||||
row-gap: 0.125rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.mg-week:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.mg-bg {
|
||||
border-left: 1px solid var(--border);
|
||||
min-width: 0;
|
||||
}
|
||||
.mg-bg:nth-child(7n + 1) {
|
||||
border-left: none;
|
||||
}
|
||||
.mg-bg.weekend {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.mg-bg.outside {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
.mg-bg.today {
|
||||
background: color-mix(in srgb, var(--accent) 6%, #fff);
|
||||
}
|
||||
.mg-dnum {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0.25rem 0.4375rem 0;
|
||||
}
|
||||
.mg-dn {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
.mg-dnum.outside .mg-dn {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.mg-dnum.today .mg-dn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.mg-bar {
|
||||
z-index: 1;
|
||||
margin: 0 0.25rem;
|
||||
padding: 0 0.4375rem;
|
||||
height: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-left: 3px solid var(--c);
|
||||
border-radius: 0.3125rem;
|
||||
background: var(--w);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
font-family: inherit;
|
||||
}
|
||||
.mg-bar:hover {
|
||||
filter: brightness(0.97);
|
||||
}
|
||||
.mg-bar.sel {
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
.mg-bt {
|
||||
font-size: 0.719rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mg-more {
|
||||
z-index: 1;
|
||||
align-self: start;
|
||||
margin: 0 0.4375rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-3);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user