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:
2026-06-23 12:45:52 +09:00
parent 3f134ccca3
commit 4ff746638c
4 changed files with 447 additions and 26 deletions
@@ -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>
+29 -6
View File
@@ -1,13 +1,15 @@
<script setup lang="ts">
// 일정 — 전사 공유 주간 타임라인(간트형) 캘린더
// 일정 — 전사 공유 캘린더(주간 간트 / 월간 달력 그리드 전환)
import { computed, onMounted, ref } from 'vue'
import AppShell from '@/layouts/AppShell.vue'
import ScheduleTimeline from '@/components/schedule/ScheduleTimeline.vue'
import ScheduleMonthGrid from '@/components/schedule/ScheduleMonthGrid.vue'
import ScheduleDetailPanel from '@/components/schedule/ScheduleDetailPanel.vue'
import ScheduleEventModal from '@/components/schedule/ScheduleEventModal.vue'
import ScheduleCategoryModal from '@/components/schedule/ScheduleCategoryModal.vue'
import ScheduleTypeFilter from '@/components/schedule/ScheduleTypeFilter.vue'
import { useScheduleStore } from '@/stores/schedule.store'
import SegmentedTabs from '@/components/common/SegmentedTabs.vue'
import { useScheduleStore, type ScheduleViewMode } from '@/stores/schedule.store'
import { useDialogStore } from '@/stores/dialog.store'
import type {
ApiScheduleEvent,
@@ -18,6 +20,12 @@ import type {
const store = useScheduleStore()
const dialog = useDialogStore()
// 보기 모드 토글 옵션
const VIEW_OPTIONS: { value: ScheduleViewMode; label: string }[] = [
{ value: 'week', label: '주간' },
{ value: 'month', label: '월간' },
]
const selId = ref<string | null>(null)
const eventModal = ref<{ init: ApiScheduleEvent | null } | null>(null)
const catModal = ref<{ init: import('@/types/schedule').ApiScheduleCategory | null } | null>(null)
@@ -107,8 +115,8 @@ async function onDeleteCategory(id: string): Promise<void> {
<div class="navgroup">
<button
class="navbtn"
title="이전 주"
@click="store.goPrevWeek()"
:title="store.viewMode === 'week' ? '이전 주' : '이전 달'"
@click="store.goPrev()"
>
<svg
viewBox="0 0 24 24"
@@ -121,8 +129,8 @@ async function onDeleteCategory(id: string): Promise<void> {
</button>
<button
class="navbtn"
title="다음 주"
@click="store.goNextWeek()"
:title="store.viewMode === 'week' ? '다음 주' : '다음 달'"
@click="store.goNext()"
>
<svg
viewBox="0 0 24 24"
@@ -136,6 +144,11 @@ async function onDeleteCategory(id: string): Promise<void> {
</div>
<span class="range">{{ store.rangeLabel }}</span>
</div>
<SegmentedTabs
:model-value="store.viewMode"
:options="VIEW_OPTIONS"
@update:model-value="store.setViewMode"
/>
<div class="spacer" />
<ScheduleTypeFilter
:categories="store.categories"
@@ -166,6 +179,7 @@ async function onDeleteCategory(id: string): Promise<void> {
<div class="content-row">
<div class="viewport">
<ScheduleTimeline
v-if="store.viewMode === 'week'"
:categories="store.categories"
:events="store.events"
:days="store.days"
@@ -174,6 +188,15 @@ async function onDeleteCategory(id: string): Promise<void> {
@select="selId = $event"
@add-category="openNewCategory"
/>
<ScheduleMonthGrid
v-else
:weeks="store.monthWeeks"
:events="store.events"
:categories="store.categories"
:active-type-ids="store.activeTypeIds"
:sel-id="selId"
@select="selId = $event"
/>
</div>
<div
v-if="selEvent"
+60
View File
@@ -92,3 +92,63 @@ export function eventDateText(start: string, end: string): string {
export function spanDays(start: string, end: string): number {
return Math.round((dateOf(end).getTime() - dateOf(start).getTime()) / 86400000) + 1
}
// ===== 월간(달력 그리드) =====
// 해당 월의 1일 ISO
export function firstOfMonth(iso: string): string {
const d = dateOf(iso)
return isoOf(new Date(d.getFullYear(), d.getMonth(), 1))
}
// iso + n개월 (말일 넘침 보정)
export function addMonths(iso: string, n: number): string {
const d = dateOf(iso)
const day = d.getDate()
const base = new Date(d.getFullYear(), d.getMonth() + n, 1)
const dim = new Date(base.getFullYear(), base.getMonth() + 1, 0).getDate()
base.setDate(Math.min(day, dim))
return isoOf(base)
}
// 월 라벨 — '2026년 6월'
export function monthLabel(iso: string): string {
const d = dateOf(iso)
return `${d.getFullYear()}${d.getMonth() + 1}`
}
// 달력 그리드의 하루(요일 + 이번 달 여부)
export interface MonthDay extends WeekDay {
inMonth: boolean
}
// 월 달력 그리드 — 1일이 속한 주의 월요일부터 말일이 속한 주의 일요일까지(4~6주)
export function monthGridWeeks(iso: string): MonthDay[][] {
const monthIdx = dateOf(firstOfMonth(iso)).getMonth()
const gridStart = mondayOf(firstOfMonth(iso))
const first = dateOf(firstOfMonth(iso))
const lastDayIso = isoOf(new Date(first.getFullYear(), first.getMonth() + 1, 0))
const gridEnd = addDays(mondayOf(lastDayIso), 6)
const today = todayIso()
const weeks: MonthDay[][] = []
let cur = gridStart
while (cur <= gridEnd) {
const week: MonthDay[] = []
for (let i = 0; i < 7; i++) {
const dayIso = addDays(cur, i)
const dt = dateOf(dayIso)
week.push({
iso: dayIso,
dow: DOW_KO[i] ?? '',
d: dt.getDate(),
weekend: i >= 5,
isToday: dayIso === today,
inMonth: dt.getMonth() === monthIdx,
})
}
weeks.push(week)
cur = addDays(cur, 7)
}
return weeks
}
+63 -20
View File
@@ -3,10 +3,15 @@ import { defineStore } from 'pinia'
import { useSchedule } from '@/composables/useSchedule'
import {
addDays,
addMonths,
firstOfMonth,
mondayOf,
monthGridWeeks,
monthLabel,
todayIso,
weekDays,
weekRangeLabel,
type MonthDay,
type WeekDay,
} from '@/shared/utils/scheduleDate'
import type {
@@ -17,7 +22,9 @@ import type {
ScheduleEventPayload,
} from '@/types/schedule'
// 일정 스토어 — 카테고리/일정/참석자 + 주간 네비 상태 + CRUD
export type ScheduleViewMode = 'week' | 'month'
// 일정 스토어 — 카테고리/일정/참석자 + 주간/월간 네비 상태 + CRUD
export const useScheduleStore = defineStore('schedule', () => {
const api = useSchedule()
@@ -26,16 +33,38 @@ export const useScheduleStore = defineStore('schedule', () => {
const people = ref<ApiSchedulePerson[]>([])
// 활성(표시) 카테고리 id 집합 — 유형 필터
const activeTypeIds = ref<Set<string>>(new Set())
// 주 시작(월요일) ISO
const weekStart = ref<string>(mondayOf(todayIso()))
// 보기 모드(주간/월간) + 기준 날짜(이 날짜가 속한 주/월을 표시)
const viewMode = ref<ScheduleViewMode>('week')
const anchor = ref<string>(todayIso())
const loading = ref(false)
// 파생: 주간 7일
// --- 파생: 표시 단위 ---
// 주간: 월요일 시작 7일
const weekStart = computed(() => mondayOf(anchor.value))
const days = computed<WeekDay[]>(() => weekDays(weekStart.value))
const rangeLabel = computed(() => weekRangeLabel(weekStart.value))
const weekEnd = computed(() => addDays(weekStart.value, 6))
// 월간: 달력 그리드(주 배열)
const monthWeeks = computed<MonthDay[][]>(() => monthGridWeeks(anchor.value))
// 카테고리별 현재 주 일정 수(필터 드롭다운 표기용)
// 조회 범위(현재 모드가 화면에 보여주는 전체 기간)
const rangeStart = computed(() => {
if (viewMode.value === 'week') return weekStart.value
const w = monthWeeks.value
return w[0]?.[0]?.iso ?? weekStart.value
})
const rangeEnd = computed(() => {
if (viewMode.value === 'week') return addDays(weekStart.value, 6)
const w = monthWeeks.value
return w[w.length - 1]?.[6]?.iso ?? addDays(weekStart.value, 6)
})
// 상단 범위 라벨 — 주간: '2026년 6월 23 29일' / 월간: '2026년 6월'
const rangeLabel = computed(() =>
viewMode.value === 'week'
? weekRangeLabel(weekStart.value)
: monthLabel(anchor.value),
)
// 카테고리별 현재 범위 일정 수(필터 드롭다운 표기용)
const counts = computed<Record<string, number>>(() => {
const c: Record<string, number> = {}
for (const e of events.value) c[e.categoryId] = (c[e.categoryId] || 0) + 1
@@ -44,14 +73,14 @@ export const useScheduleStore = defineStore('schedule', () => {
// --- 로딩 ---
// 최초 진입 — 카테고리/참석자/주간 일정 동시 로드 + 필터 전체 활성화
// 최초 진입 — 카테고리/참석자/현재 범위 일정 동시 로드 + 필터 전체 활성화
async function init(): Promise<void> {
loading.value = true
try {
const [cats, ppl, evs] = await Promise.all([
api.listCategories(),
api.listPeople(),
api.listEvents(weekStart.value, weekEnd.value),
api.listEvents(rangeStart.value, rangeEnd.value),
])
categories.value = cats
people.value = ppl
@@ -62,22 +91,33 @@ export const useScheduleStore = defineStore('schedule', () => {
}
}
// 현재 일정만 재조회
// 현재 범위 일정만 재조회
async function reloadEvents(): Promise<void> {
events.value = await api.listEvents(weekStart.value, weekEnd.value)
events.value = await api.listEvents(rangeStart.value, rangeEnd.value)
}
// --- 주간 네비 ---
function goPrevWeek(): void {
weekStart.value = addDays(weekStart.value, -7)
// --- 네비(모드별) ---
function setViewMode(mode: ScheduleViewMode): void {
if (viewMode.value === mode) return
viewMode.value = mode
void reloadEvents()
}
function goNextWeek(): void {
weekStart.value = addDays(weekStart.value, 7)
function goPrev(): void {
anchor.value =
viewMode.value === 'week'
? addDays(anchor.value, -7)
: addMonths(firstOfMonth(anchor.value), -1)
void reloadEvents()
}
function goNext(): void {
anchor.value =
viewMode.value === 'week'
? addDays(anchor.value, 7)
: addMonths(firstOfMonth(anchor.value), 1)
void reloadEvents()
}
function goToday(): void {
weekStart.value = mondayOf(todayIso())
anchor.value = todayIso()
void reloadEvents()
}
@@ -135,7 +175,7 @@ export const useScheduleStore = defineStore('schedule', () => {
const n = new Set(activeTypeIds.value)
n.delete(id)
activeTypeIds.value = n
// 포함 일정이 cascade 삭제되었으므로 주간 일정 재조회
// 포함 일정이 cascade 삭제되었으므로 재조회
await reloadEvents()
}
@@ -150,15 +190,18 @@ export const useScheduleStore = defineStore('schedule', () => {
events,
people,
activeTypeIds,
viewMode,
weekStart,
loading,
days,
monthWeeks,
rangeLabel,
counts,
init,
reloadEvents,
goPrevWeek,
goNextWeek,
setViewMode,
goPrev,
goNext,
goToday,
toggleType,
setAllTypes,