feat: 전사 공유 일정(주간 캘린더) 기능 추가
상단 nav 독립 메뉴 '일정' — 주간 타임라인(간트형) 캘린더. 카테고리=레인, 일정=막대(다중일 지원), 우측 상세 패널, 일정/카테고리 CRUD. 백엔드(Schedule 모듈): - ScheduleCategory/ScheduleEvent 엔티티 + 참석자(다대다) + 작성자 - 카테고리/일정 CRUD, 기간 겹침 조회, 참석자 풀(전사 사용자) - 기본 카테고리 12종 onModuleInit 멱등 시드 - 인증 사용자 누구나 관리(전역 일정, 글로벌 관리자 개념 없음) - 운영 마이그레이션(AddSchedule) 추가, @nestjs/schedule 임포트는 CronModule 로 alias 프론트: - /schedule 라우트 + 사이드바 nav 항목 - 주간 네비(이전/다음/오늘), 유형 필터·카테고리 관리 드롭다운 - 타임라인/상세 패널/일정 모달/카테고리 모달(hue 슬라이더)/참석자 멀티셀렉트 - schedule.store + useSchedule + scheduleDate 유틸 런타임 검증: dev 부팅·시드(12건)·라우트 매핑 정상, 인증 게이트(401), 일정 생성/조회/수정/삭제 + 참석자/전직원/다중일 스모크 전부 정상. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
<script setup lang="ts">
|
||||
// 일정 — 전사 공유 주간 타임라인(간트형) 캘린더
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import ScheduleTimeline from '@/components/schedule/ScheduleTimeline.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 { useDialogStore } from '@/stores/dialog.store'
|
||||
import type {
|
||||
ApiScheduleEvent,
|
||||
ScheduleCategoryPayload,
|
||||
ScheduleEventPayload,
|
||||
} from '@/types/schedule'
|
||||
|
||||
const store = useScheduleStore()
|
||||
const dialog = useDialogStore()
|
||||
|
||||
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)
|
||||
|
||||
onMounted(() => {
|
||||
void store.init().catch(() => {
|
||||
// 인터셉터가 에러 토스트 처리
|
||||
})
|
||||
})
|
||||
|
||||
// 선택 일정 — 활성 유형 + 현재 주에 보이는 것만
|
||||
const selEvent = computed<ApiScheduleEvent | null>(() => {
|
||||
const ev = store.events.find((e) => e.id === selId.value)
|
||||
if (!ev) return null
|
||||
return store.activeTypeIds.has(ev.categoryId) ? ev : null
|
||||
})
|
||||
const selCategory = computed(
|
||||
() => store.categories.find((c) => c.id === selEvent.value?.categoryId) ?? null,
|
||||
)
|
||||
|
||||
// 새 일정 기본 날짜 — 보고 있는 주의 월요일
|
||||
const defaultDate = computed(() => store.weekStart)
|
||||
|
||||
// ----- 일정 -----
|
||||
function openNewEvent(): void {
|
||||
eventModal.value = { init: null }
|
||||
}
|
||||
function openEditEvent(ev: ApiScheduleEvent): void {
|
||||
eventModal.value = { init: ev }
|
||||
}
|
||||
async function onSaveEvent(payload: ScheduleEventPayload): Promise<void> {
|
||||
const init = eventModal.value?.init
|
||||
if (init) {
|
||||
await store.updateEvent(init.id, payload)
|
||||
selId.value = init.id
|
||||
} else {
|
||||
selId.value = await store.createEvent(payload)
|
||||
}
|
||||
eventModal.value = null
|
||||
}
|
||||
async function onDeleteEvent(ev: ApiScheduleEvent): Promise<void> {
|
||||
const ok = await dialog.confirm(
|
||||
`'${ev.title}' 일정을 삭제할까요? 이 작업은 되돌릴 수 없습니다.`,
|
||||
{ title: '일정 삭제', confirmText: '삭제', variant: 'danger' },
|
||||
)
|
||||
if (!ok) return
|
||||
await store.deleteEvent(ev.id)
|
||||
if (selId.value === ev.id) selId.value = null
|
||||
}
|
||||
|
||||
// ----- 카테고리 -----
|
||||
function openNewCategory(): void {
|
||||
catModal.value = { init: null }
|
||||
}
|
||||
function openEditCategory(id: string): void {
|
||||
catModal.value = { init: store.categories.find((c) => c.id === id) ?? null }
|
||||
}
|
||||
async function onSaveCategory(payload: ScheduleCategoryPayload): Promise<void> {
|
||||
const init = catModal.value?.init
|
||||
if (init) await store.updateCategory(init.id, payload)
|
||||
else await store.createCategory(payload)
|
||||
catModal.value = null
|
||||
}
|
||||
async function onDeleteCategory(id: string): Promise<void> {
|
||||
const cat = store.categories.find((c) => c.id === id)
|
||||
if (!cat) return
|
||||
const cnt = store.counts[id] || 0
|
||||
const msg = cnt
|
||||
? `'${cat.label}' 카테고리와 이번 주에 표시된 일정 ${cnt}건을 포함해 해당 카테고리의 모든 일정이 삭제됩니다. 계속할까요?`
|
||||
: `'${cat.label}' 카테고리를 삭제할까요?`
|
||||
const ok = await dialog.confirm(msg, {
|
||||
title: '카테고리 삭제',
|
||||
confirmText: '삭제',
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
await store.deleteCategory(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="sched">
|
||||
<!-- 툴바 -->
|
||||
<div class="toolbar">
|
||||
<div class="datenav">
|
||||
<div class="navgroup">
|
||||
<button
|
||||
class="navbtn"
|
||||
title="이전 주"
|
||||
@click="store.goPrevWeek()"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m15 18-6-6 6-6" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="navbtn"
|
||||
title="다음 주"
|
||||
@click="store.goNextWeek()"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m9 18 6-6-6-6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="today-btn"
|
||||
@click="store.goToday()"
|
||||
>
|
||||
오늘
|
||||
</button>
|
||||
<span class="range">{{ store.rangeLabel }}</span>
|
||||
</div>
|
||||
<div class="spacer" />
|
||||
<ScheduleTypeFilter
|
||||
:categories="store.categories"
|
||||
:counts="store.counts"
|
||||
:active-type-ids="store.activeTypeIds"
|
||||
@toggle="store.toggleType"
|
||||
@set-all="store.setAllTypes"
|
||||
@add="openNewCategory"
|
||||
@edit="openEditCategory"
|
||||
@delete="onDeleteCategory"
|
||||
/>
|
||||
<button
|
||||
class="btn primary"
|
||||
@click="openNewEvent"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M5 12h14M12 5v14" /></svg>새 일정
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 콘텐츠 + 상세 패널 -->
|
||||
<div class="content-row">
|
||||
<div class="viewport">
|
||||
<ScheduleTimeline
|
||||
:categories="store.categories"
|
||||
:events="store.events"
|
||||
:days="store.days"
|
||||
:active-type-ids="store.activeTypeIds"
|
||||
:sel-id="selId"
|
||||
@select="selId = $event"
|
||||
@add-category="openNewCategory"
|
||||
@edit-category="openEditCategory"
|
||||
@delete-category="onDeleteCategory"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="selEvent"
|
||||
class="detail-backdrop"
|
||||
@click="selId = null"
|
||||
/>
|
||||
<ScheduleDetailPanel
|
||||
:event="selEvent"
|
||||
:category="selCategory"
|
||||
@close="selId = null"
|
||||
@edit="openEditEvent"
|
||||
@delete="onDeleteEvent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScheduleEventModal
|
||||
v-if="eventModal"
|
||||
:categories="store.categories"
|
||||
:people="store.people"
|
||||
:init="eventModal.init"
|
||||
:default-date="defaultDate"
|
||||
@save="onSaveEvent"
|
||||
@close="eventModal = null"
|
||||
/>
|
||||
<ScheduleCategoryModal
|
||||
v-if="catModal"
|
||||
:init="catModal.init"
|
||||
@save="onSaveCategory"
|
||||
@close="catModal = null"
|
||||
/>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 상단 topbar(3.25rem) 아래 영역을 가득 채우는 풀-하이트 레이아웃 */
|
||||
.sched {
|
||||
height: calc(100vh - 3.25rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 11px 24px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
.datenav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.datenav .range {
|
||||
font-size: 15.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
white-space: nowrap;
|
||||
margin: 0 6px;
|
||||
}
|
||||
.navgroup {
|
||||
display: flex;
|
||||
}
|
||||
.navgroup .navbtn:first-child {
|
||||
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
|
||||
}
|
||||
.navgroup .navbtn:last-child {
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
margin-left: -1px;
|
||||
}
|
||||
.navbtn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #fff;
|
||||
color: var(--text-2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.navbtn:hover {
|
||||
background: #f7f8fa;
|
||||
color: var(--text);
|
||||
z-index: 1;
|
||||
}
|
||||
.navbtn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.today-btn {
|
||||
height: 32px;
|
||||
padding: 0 13px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.today-btn:hover {
|
||||
background: #f7f8fa;
|
||||
color: var(--text);
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.btn {
|
||||
height: 36px;
|
||||
padding: 0 15px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--panel);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
.btn.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
.content-row {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
background: var(--bg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.viewport {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
padding: 18px 24px 28px;
|
||||
}
|
||||
.detail-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 25;
|
||||
background: rgba(20, 24, 33, 0.22);
|
||||
opacity: 0;
|
||||
animation: bd-in 0.22s ease forwards;
|
||||
}
|
||||
@keyframes bd-in {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user