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:
2026-06-22 21:23:10 +09:00
parent 62df92e6eb
commit 346ec75553
22 changed files with 3889 additions and 5 deletions
@@ -0,0 +1,68 @@
<script setup lang="ts">
// 일정 참석자 아바타 — avatarUrl 있으면 이미지, 없으면 이름 첫 글자 + 파생색
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
name: string
avatarUrl?: string | null
size?: number
mono?: boolean
}>(),
{ avatarUrl: null, size: 21, mono: false },
)
// 이름 → 안정적 색상(시안 avColor 포팅)
const AV_COLORS = [
'#d0982a',
'#e0518d',
'#3d8bf2',
'#16a37b',
'#b3631f',
'#7c5cf0',
'#0e9594',
'#d6455d',
]
const color = computed(() => {
const sum = [...(props.name || '?')].reduce((a, c) => a + c.charCodeAt(0), 0)
return AV_COLORS[sum % AV_COLORS.length]
})
const initial = computed(() => (props.name || '?').trim()[0] ?? '?')
const bg = computed(() => (props.mono ? '#aeb4bd' : color.value))
</script>
<template>
<img
v-if="avatarUrl"
class="sa"
:src="avatarUrl"
:alt="name"
:style="{ width: `${size}px`, height: `${size}px` }"
>
<span
v-else
class="sa sa-ph"
:style="{
width: `${size}px`,
height: `${size}px`,
fontSize: `${size * 0.47}px`,
background: bg,
}"
>{{ initial }}</span>
</template>
<style scoped>
.sa {
border-radius: 50%;
flex-shrink: 0;
object-fit: cover;
display: block;
}
.sa-ph {
display: grid;
place-items: center;
font-weight: 700;
color: #fff;
line-height: 1;
}
</style>
@@ -0,0 +1,317 @@
<script setup lang="ts">
// 카테고리 생성/수정 모달 — 이름 + 색상(hue 슬라이더)
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import type {
ApiScheduleCategory,
ScheduleCategoryPayload,
} from '@/types/schedule'
const props = defineProps<{ init: ApiScheduleCategory | null }>()
const emit = defineEmits<{
(e: 'save', payload: ScheduleCategoryPayload): void
(e: 'close'): void
}>()
const CAT_SAT = 62
const CAT_LIG = 46
function hslToHex(h: number, s: number, l: number): string {
s /= 100
l /= 100
const k = (n: number) => (n + h / 30) % 12
const a = s * Math.min(l, 1 - l)
const f = (n: number) =>
l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))
const to = (x: number) =>
Math.round(255 * x)
.toString(16)
.padStart(2, '0')
return `#${to(f(0))}${to(f(8))}${to(f(4))}`
}
function hexToHue(hex: string): number {
const m = hex.replace('#', '')
const r = parseInt(m.slice(0, 2), 16) / 255
const g = parseInt(m.slice(2, 4), 16) / 255
const b = parseInt(m.slice(4, 6), 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const d = max - min
let h = 0
if (d) {
if (max === r) h = ((g - b) / d) % 6
else if (max === g) h = (b - r) / d + 2
else h = (r - g) / d + 4
h *= 60
if (h < 0) h += 360
}
return Math.round(h)
}
const isEdit = computed(() => !!props.init)
const name = ref(props.init?.label ?? '')
const hue = ref(props.init?.color ? hexToHue(props.init.color) : 210)
const color = computed(() => hslToHex(hue.value, CAT_SAT, CAT_LIG))
const valid = computed(() => !!name.value.trim())
function submit(): void {
if (!valid.value) return
emit('save', { label: name.value.trim(), color: color.value })
}
function onKey(ev: KeyboardEvent): void {
if (ev.key === 'Escape') emit('close')
}
onMounted(() => document.addEventListener('keydown', onKey))
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
</script>
<template>
<div
class="modal-back"
@mousedown.self="emit('close')"
>
<div class="modal modal-sm">
<div class="modal-head">
<h3>{{ isEdit ? '카테고리 편집' : '새 카테고리' }}</h3>
<button
class="modal-x"
@click="emit('close')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
<div class="modal-body">
<label class="f-field"><span class="f-label">이름</span>
<input
v-model="name"
class="f-input"
placeholder="카테고리 이름"
autofocus
@keydown.enter="submit"
>
</label>
<div class="f-field">
<span class="f-label">색상</span>
<div class="cat-color">
<span
class="cat-dot"
:style="{ background: color }"
/>
<input
v-model.number="hue"
type="range"
min="0"
max="359"
class="hue-slider"
:style="{ '--cur': color }"
>
</div>
</div>
</div>
<div class="modal-foot">
<button
class="btn"
@click="emit('close')"
>
취소
</button>
<button
class="btn primary"
:disabled="!valid"
@click="submit"
>
{{ isEdit ? '저장' : '추가' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.modal-back {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(20, 24, 33, 0.34);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.modal {
width: 540px;
max-width: 100%;
background: var(--panel);
border-radius: 14px;
box-shadow: 0 24px 70px rgba(20, 24, 33, 0.34);
display: flex;
flex-direction: column;
}
.modal.modal-sm {
width: 408px;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 17px 22px;
border-bottom: 1px solid var(--border);
}
.modal-head h3 {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.2px;
}
.modal-x {
width: 30px;
height: 30px;
border: none;
background: transparent;
color: var(--text-3);
border-radius: 6px;
cursor: pointer;
display: grid;
place-items: center;
margin-right: -5px;
}
.modal-x:hover {
background: #f1f2f4;
color: var(--text);
}
.modal-x svg {
width: 17px;
height: 17px;
}
.modal-body {
padding: 18px 22px;
display: flex;
flex-direction: column;
gap: 14px;
}
.modal-foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 13px 22px;
border-top: 1px solid var(--border);
}
.f-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.f-label {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
}
.f-input {
width: 100%;
height: 38px;
padding: 0 11px;
border: 1px solid var(--border-strong);
border-radius: 7px;
font-family: inherit;
font-size: 13.5px;
color: var(--text);
background: #fff;
}
.f-input::placeholder {
color: var(--text-3);
}
.f-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.cat-color {
display: flex;
align-items: center;
gap: 12px;
}
.cat-dot {
width: 24px;
height: 24px;
border-radius: 7px;
flex-shrink: 0;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.hue-slider {
-webkit-appearance: none;
appearance: none;
flex: 1;
height: 16px;
margin: 6px 0;
border-radius: 8px;
cursor: pointer;
outline: none;
background: linear-gradient(
to right,
#f00 0%,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f00 100%
);
}
.hue-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--cur);
border: 3px solid #fff;
box-shadow: 0 1px 4px rgba(20, 24, 33, 0.4);
cursor: grab;
}
.hue-slider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--cur);
border: 3px solid #fff;
box-shadow: 0 1px 4px rgba(20, 24, 33, 0.4);
cursor: grab;
}
.btn {
height: 38px;
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;
}
.btn:hover {
background: #f7f8fa;
color: var(--text);
}
.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);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,493 @@
<script setup lang="ts">
// 우측 슬라이드 상세 패널 — 선택 일정의 시간/장소/참석자/메모 + 수정·삭제
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import ScheduleAvatar from './ScheduleAvatar.vue'
import { eventDateText, spanDays } from '@/shared/utils/scheduleDate'
import type { ApiScheduleCategory, ApiScheduleEvent } from '@/types/schedule'
const props = defineProps<{
event: ApiScheduleEvent | null
category: ApiScheduleCategory | null
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'edit', ev: ApiScheduleEvent): void
(e: 'delete', ev: ApiScheduleEvent): void
}>()
// 닫히는 애니메이션 동안 직전 내용 유지
const shown = ref<ApiScheduleEvent | null>(props.event)
watch(
() => props.event,
(v) => {
if (v) shown.value = v
},
)
const open = computed(() => !!props.event)
const ev = computed(() => props.event ?? shown.value)
const multi = computed(() => (ev.value ? ev.value.start !== ev.value.end : false))
const dateText = computed(() =>
ev.value ? eventDateText(ev.value.start, ev.value.end) : '',
)
const dur = computed(() =>
ev.value && multi.value ? spanDays(ev.value.start, ev.value.end) : 0,
)
// 참석자 스택 토글
const attOpen = ref(false)
const attRef = ref<HTMLElement | null>(null)
function onDoc(e: MouseEvent): void {
if (!attOpen.value) return
if (attRef.value && !attRef.value.contains(e.target as Node)) attOpen.value = false
}
watch(attOpen, (v) => {
if (v) document.addEventListener('mousedown', onDoc)
else document.removeEventListener('mousedown', onDoc)
})
onBeforeUnmount(() => document.removeEventListener('mousedown', onDoc))
const MAX = 6
const attendees = computed(() => ev.value?.attendees ?? [])
const shownAtt = computed(() =>
attendees.value.length > MAX
? attendees.value.slice(0, MAX - 1)
: attendees.value,
)
const extra = computed(() =>
attendees.value.length > MAX ? attendees.value.length - (MAX - 1) : 0,
)
</script>
<template>
<aside
class="detail"
:class="{ open }"
>
<template v-if="ev">
<div class="dt-head">
<div class="dt-top">
<span
v-if="category"
class="type-chip"
:style="{
background: `color-mix(in srgb, ${category.color} 13%, #fff)`,
color: category.color,
}"
>{{ category.label }}</span>
<span v-else />
<button
class="dt-close"
@click="emit('close')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
<h2>{{ ev.title }}</h2>
<div class="dt-date">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><rect
x="3"
y="4"
width="18"
height="18"
rx="2"
/><path d="M16 2v4M8 2v4M3 10h18" /></svg>
{{ dateText }}
<span
v-if="multi"
class="dt-dur"
>{{ dur }}일간</span>
</div>
</div>
<div class="dt-body">
<div class="dt-list">
<div class="dt-item">
<span class="dt-k">시간</span>
<span class="dt-v">{{ ev.time || '—' }}</span>
</div>
<div
v-if="ev.place && ev.place !== '—'"
class="dt-item"
>
<span class="dt-k">장소</span>
<span class="dt-v">{{ ev.place }}</span>
</div>
<div class="dt-item">
<span class="dt-k">참석자</span>
<span class="dt-v">
<template v-if="ev.allHands"> 직원 대상</template>
<div
v-else-if="attendees.length > 0"
ref="attRef"
class="att-stack"
>
<button
class="att-trigger"
:class="{ open: attOpen }"
@click="attOpen = !attOpen"
>
<span class="avstack">
<ScheduleAvatar
v-for="p in shownAtt"
:key="p.id"
:name="p.name"
:avatar-url="p.avatarUrl"
:size="30"
/>
<span
v-if="extra > 0"
class="av-more"
>+{{ extra }}</span>
</span>
<span class="att-chev">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m6 9 6 6 6-6" /></svg>
</span>
</button>
<div
v-if="attOpen"
class="att-panel"
>
<div
v-for="p in attendees"
:key="p.id"
class="att-row"
>
<ScheduleAvatar
:name="p.name"
:avatar-url="p.avatarUrl"
:size="24"
/>{{ p.name }}
</div>
</div>
</div>
<template v-else>참석자 없음</template>
</span>
</div>
<div
v-if="ev.description"
class="dt-item"
>
<span class="dt-k">메모</span>
<span class="dt-v dt-v-memo">{{ ev.description }}</span>
</div>
</div>
</div>
<div class="dt-foot">
<div class="dt-actions">
<button
class="btn"
@click="emit('edit', ev)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>일정 수정
</button>
<button
class="btn danger icon-only"
title="삭제"
@click="emit('delete', ev)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M10 11v6M14 11v6" /></svg>
</button>
</div>
</div>
</template>
</aside>
</template>
<style scoped>
.detail {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 360px;
z-index: 30;
border-left: 1px solid var(--border);
background: var(--panel);
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-md);
transform: translateX(100%);
transition: transform 0.26s cubic-bezier(0.32, 0.72, 0, 1);
}
.detail.open {
transform: translateX(0);
}
.type-chip {
font-size: 11px;
font-weight: 600;
padding: 2px 9px;
border-radius: 6px;
white-space: nowrap;
line-height: 1.6;
}
.dt-head {
padding: 20px 22px 18px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.dt-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.dt-close {
width: 30px;
height: 30px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--text-3);
cursor: pointer;
display: grid;
place-items: center;
margin-right: -6px;
}
.dt-close:hover {
background: #f1f2f4;
color: var(--text);
}
.dt-close svg {
width: 17px;
height: 17px;
}
.dt-head h2 {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.3px;
line-height: 1.4;
margin: 13px 0 8px;
word-break: keep-all;
}
.dt-date {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 600;
color: var(--text-2);
}
.dt-date svg {
width: 15px;
height: 15px;
color: var(--text-3);
}
.dt-dur {
font-size: 11px;
font-weight: 700;
color: var(--accent);
background: var(--accent-weak);
border-radius: 5px;
padding: 1px 7px;
white-space: nowrap;
}
.dt-body {
padding: 18px 22px 24px;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.dt-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.dt-item {
display: flex;
gap: 14px;
align-items: flex-start;
}
.dt-k {
font-size: 12px;
font-weight: 600;
color: var(--text-3);
width: 52px;
flex-shrink: 0;
padding-top: 2px;
}
.dt-v {
flex: 1;
min-width: 0;
font-size: 13.5px;
font-weight: 500;
color: var(--text);
word-break: keep-all;
}
.dt-v-memo {
font-weight: 400;
color: var(--text-2);
line-height: 1.7;
white-space: pre-wrap;
}
.att-stack {
position: relative;
margin: -3px 0;
}
.att-trigger {
display: inline-flex;
align-items: center;
gap: 9px;
border: none;
background: none;
cursor: pointer;
padding: 5px 8px 5px 5px;
margin: -5px -8px -5px -5px;
border-radius: 9px;
}
.att-trigger:hover,
.att-trigger.open {
background: #f1f2f4;
}
.avstack {
display: inline-flex;
align-items: center;
}
.avstack :deep(.sa),
.avstack .av-more {
margin-left: -9px;
box-shadow: 0 0 0 2px var(--panel);
}
.avstack :deep(.sa):first-child {
margin-left: 0;
}
.av-more {
width: 30px;
height: 30px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 12px;
font-weight: 700;
background: #eceef1;
color: var(--text-2);
flex-shrink: 0;
}
.att-chev {
color: var(--text-3);
display: grid;
transition: transform 0.15s ease;
}
.att-chev svg {
width: 15px;
height: 15px;
}
.att-trigger.open .att-chev {
transform: rotate(180deg);
}
.att-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 200px;
max-width: 260px;
max-height: 240px;
overflow-y: auto;
z-index: 20;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow-md);
padding: 5px;
}
.att-row {
display: flex;
align-items: center;
gap: 9px;
font-size: 13px;
font-weight: 500;
color: var(--text);
padding: 6px 8px;
border-radius: 6px;
white-space: nowrap;
}
.att-row:hover {
background: #f4f5f7;
}
.dt-foot {
flex-shrink: 0;
padding: 14px 22px;
border-top: 1px solid var(--border);
background: var(--panel);
}
.dt-actions {
display: flex;
gap: 8px;
}
.dt-actions .btn {
flex: 1;
justify-content: center;
height: 36px;
}
.dt-actions .btn.icon-only {
flex: 0 0 auto;
padding: 0;
width: 36px;
}
.btn {
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:hover {
background: #f7f8fa;
color: var(--text);
}
.btn svg {
width: 15px;
height: 15px;
}
.btn.danger {
background: #fff;
border-color: #f1c9c9;
color: #dc2626;
}
.btn.danger:hover {
background: #fdeaea;
border-color: #ec9d9d;
color: #b91c1c;
}
</style>
@@ -0,0 +1,662 @@
<script setup lang="ts">
// 일정 생성/수정 모달
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ScheduleAvatar from './ScheduleAvatar.vue'
import type {
ApiScheduleCategory,
ApiScheduleEvent,
ApiSchedulePerson,
ScheduleEventPayload,
} from '@/types/schedule'
const props = defineProps<{
categories: ApiScheduleCategory[]
people: ApiSchedulePerson[]
init: ApiScheduleEvent | null
defaultDate: string
}>()
const emit = defineEmits<{
(e: 'save', payload: ScheduleEventPayload): void
(e: 'close'): void
}>()
const isEdit = computed(() => !!props.init)
// init.time → 시작/종료 HH:MM 파싱
function parseTime(tm: string): { start: string; end: string } {
if (/\d/.test(tm) && (tm.includes('') || tm.includes('-'))) {
const parts = tm.split(/[-]/).map((s) => s.trim())
const a = parts[0] ?? ''
const b = parts[1] ?? ''
return {
start: /^\d{1,2}:\d{2}/.test(a) ? a : '09:00',
end: /^\d{1,2}:\d{2}/.test(b) ? b : '',
}
}
if (/^\d{1,2}:\d{2}/.test(tm)) return { start: tm.trim(), end: '' }
return { start: '09:00', end: '10:00' }
}
const i = props.init
const pt = parseTime(i?.time ?? '')
const ordered = computed(() =>
[...props.categories].sort((a, b) => a.sortOrder - b.sortOrder),
)
const title = ref(i?.title ?? '')
const categoryId = ref(i?.categoryId ?? ordered.value[0]?.id ?? '')
const s = ref(i?.start ?? props.defaultDate)
const e = ref(i?.end ?? i?.start ?? props.defaultDate)
const startT = ref(pt.start)
const endT = ref(pt.end)
const place = ref(i?.place && i.place !== '—' ? i.place : '')
const desc = ref(i?.description ?? '')
const allHands = ref(i?.allHands ?? false)
const attendeeIds = ref<string[]>(i?.attendees.map((a) => a.id) ?? [])
// 시작일 변경 시 종료일이 더 빠르면 맞춰줌
watch(s, (v) => {
if (e.value < v) e.value = v
})
const valid = computed(
() =>
!!title.value.trim() &&
!!categoryId.value &&
!!s.value &&
!!e.value &&
e.value >= s.value &&
!!startT.value,
)
// 참석자 멀티셀렉트
const msOpen = ref(false)
const msRef = ref<HTMLElement | null>(null)
const msLabel = computed(() => {
if (allHands.value) return '전 직원'
if (attendeeIds.value.length === 0) return '참석자 선택'
return props.people
.filter((p) => attendeeIds.value.includes(p.id))
.map((p) => p.name)
.join(', ')
})
function toggleAll(): void {
allHands.value = !allHands.value
if (allHands.value) attendeeIds.value = []
}
function togglePerson(id: string): void {
allHands.value = false
attendeeIds.value = attendeeIds.value.includes(id)
? attendeeIds.value.filter((x) => x !== id)
: [...attendeeIds.value, id]
}
function onDoc(ev: MouseEvent): void {
if (msRef.value && !msRef.value.contains(ev.target as Node)) msOpen.value = false
}
watch(msOpen, (v) => {
if (v) document.addEventListener('mousedown', onDoc)
else document.removeEventListener('mousedown', onDoc)
})
function onKey(ev: KeyboardEvent): void {
if (ev.key === 'Escape') emit('close')
}
onMounted(() => document.addEventListener('keydown', onKey))
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', onDoc)
})
function submit(): void {
if (!valid.value) return
const time = endT.value ? `${startT.value} ${endT.value}` : startT.value
emit('save', {
title: title.value.trim(),
categoryId: categoryId.value,
start: s.value,
end: e.value,
time,
place: place.value.trim(),
description: desc.value.trim(),
allHands: allHands.value,
attendeeIds: allHands.value ? [] : attendeeIds.value,
})
}
</script>
<template>
<div
class="modal-back"
@mousedown.self="emit('close')"
>
<div class="modal">
<div class="modal-head">
<h3>{{ isEdit ? '일정 수정' : '새 일정' }}</h3>
<button
class="modal-x"
@click="emit('close')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
<div class="modal-body">
<label class="f-field"><span class="f-label">제목</span>
<input
v-model="title"
class="f-input"
placeholder="일정 제목"
autofocus
>
</label>
<label class="f-field"><span class="f-label">유형</span>
<div class="f-select-wrap">
<select
v-model="categoryId"
class="f-select"
>
<option
v-for="c in ordered"
:key="c.id"
:value="c.id"
>
{{ c.label }}
</option>
</select>
<span class="f-select-chev">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m6 9 6 6 6-6" /></svg>
</span>
</div>
</label>
<div class="f-row">
<label class="f-field"><span class="f-label">시작일</span>
<input
v-model="s"
type="date"
class="f-input"
>
</label>
<label class="f-field"><span class="f-label">종료일</span>
<input
v-model="e"
type="date"
class="f-input"
:min="s"
>
</label>
</div>
<div class="f-field">
<span class="f-label">시간</span>
<div class="time-row">
<input
v-model="startT"
type="time"
class="f-input time-input"
>
<span class="time-dash"></span>
<input
v-model="endT"
type="time"
class="f-input time-input"
>
</div>
</div>
<label class="f-field"><span class="f-label">장소</span>
<input
v-model="place"
class="f-input"
placeholder="장소 또는 화상"
>
</label>
<div class="f-field">
<span class="f-label">참석자</span>
<div
ref="msRef"
class="ms"
>
<button
type="button"
class="ms-field"
:class="{ empty: !allHands && attendeeIds.length === 0, open: msOpen }"
@click="msOpen = !msOpen"
>
<span class="ms-val">{{ msLabel }}</span>
<span class="chev">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m6 9 6 6 6-6" /></svg>
</span>
</button>
<div
v-if="msOpen"
class="ms-panel"
>
<button
type="button"
class="ms-item"
:class="{ on: allHands }"
@click="toggleAll"
>
<span class="ms-all"></span>
<span class="ms-name"> 직원</span>
<span
v-if="allHands"
class="ms-check"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
</span>
</button>
<div class="ms-div" />
<button
v-for="p in people"
:key="p.id"
type="button"
class="ms-item"
:class="{ on: attendeeIds.includes(p.id) }"
@click="togglePerson(p.id)"
>
<ScheduleAvatar
:name="p.name"
:avatar-url="p.avatarUrl"
:size="20"
/>
<span class="ms-name">{{ p.name }}</span>
<span
v-if="attendeeIds.includes(p.id)"
class="ms-check"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
</span>
</button>
<div
v-if="people.length === 0"
class="ms-empty"
>
등록된 사용자가 없습니다
</div>
</div>
</div>
</div>
<label class="f-field"><span class="f-label">설명</span>
<textarea
v-model="desc"
class="f-input f-textarea"
placeholder="메모"
rows="3"
/>
</label>
</div>
<div class="modal-foot">
<button
class="btn"
@click="emit('close')"
>
취소
</button>
<button
class="btn primary"
:disabled="!valid"
@click="submit"
>
{{ isEdit ? '저장' : '추가' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.modal-back {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(20, 24, 33, 0.34);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.modal {
width: 540px;
max-width: 100%;
max-height: 90vh;
overflow-y: auto;
background: var(--panel);
border-radius: 14px;
box-shadow: 0 24px 70px rgba(20, 24, 33, 0.34);
display: flex;
flex-direction: column;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 17px 22px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--panel);
border-radius: 14px 14px 0 0;
}
.modal-head h3 {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.2px;
}
.modal-x {
width: 30px;
height: 30px;
border: none;
background: transparent;
color: var(--text-3);
border-radius: 6px;
cursor: pointer;
display: grid;
place-items: center;
margin-right: -5px;
}
.modal-x:hover {
background: #f1f2f4;
color: var(--text);
}
.modal-x svg {
width: 17px;
height: 17px;
}
.modal-body {
padding: 18px 22px;
display: flex;
flex-direction: column;
gap: 14px;
}
.modal-foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 13px 22px;
border-top: 1px solid var(--border);
position: sticky;
bottom: 0;
background: var(--panel);
}
.f-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.f-label {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
}
.f-input {
width: 100%;
height: 38px;
padding: 0 11px;
border: 1px solid var(--border-strong);
border-radius: 7px;
font-family: inherit;
font-size: 13.5px;
color: var(--text);
background: #fff;
}
.f-input::placeholder {
color: var(--text-3);
}
.f-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.f-textarea {
height: auto;
padding: 9px 11px;
resize: vertical;
line-height: 1.55;
min-height: 72px;
}
.f-row {
display: flex;
gap: 12px;
}
.f-row .f-field {
flex: 1;
min-width: 0;
}
.f-select-wrap {
position: relative;
}
.f-select {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 38px;
padding: 0 34px 0 11px;
border: 1px solid var(--border-strong);
border-radius: 7px;
font-family: inherit;
font-size: 13.5px;
font-weight: 500;
color: var(--text);
background: #fff;
cursor: pointer;
}
.f-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.f-select-chev {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
display: grid;
color: var(--text-3);
pointer-events: none;
}
.f-select-chev svg {
width: 15px;
height: 15px;
}
.time-row {
display: flex;
align-items: center;
gap: 10px;
}
.time-input {
width: auto;
flex: 1;
min-width: 0;
}
.time-dash {
color: var(--text-3);
}
.ms {
position: relative;
}
.ms-field {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 38px;
padding: 0 10px 0 11px;
border: 1px solid var(--border-strong);
border-radius: 7px;
background: #fff;
font-family: inherit;
font-size: 13.5px;
color: var(--text);
cursor: pointer;
text-align: left;
}
.ms-field.empty .ms-val {
color: var(--text-3);
}
.ms-field.open {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.ms-field .ms-val {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.ms-field .chev {
display: grid;
color: var(--text-3);
}
.ms-field .chev svg {
width: 15px;
height: 15px;
}
.ms-panel {
position: absolute;
top: calc(100% + 5px);
left: 0;
right: 0;
z-index: 10;
max-height: 220px;
overflow-y: auto;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 9px;
box-shadow: var(--shadow-pop);
padding: 5px;
}
.ms-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
border: none;
background: none;
font-family: inherit;
font-size: 13px;
font-weight: 600;
color: var(--text-2);
padding: 7px 8px;
border-radius: 6px;
cursor: pointer;
text-align: left;
}
.ms-item:hover {
background: #f4f5f7;
}
.ms-item.on {
color: var(--text);
}
.ms-item .ms-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ms-item .ms-check {
flex-shrink: 0;
color: var(--accent);
display: grid;
place-items: center;
}
.ms-item .ms-check svg {
width: 15px;
height: 15px;
}
.ms-all {
width: 20px;
height: 20px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 9.5px;
font-weight: 700;
color: #fff;
background: #9298a3;
flex-shrink: 0;
}
.ms-div {
height: 1px;
background: var(--border);
margin: 4px 2px;
}
.ms-empty {
padding: 12px 8px;
font-size: 12.5px;
color: var(--text-3);
text-align: center;
}
.btn {
height: 38px;
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:hover {
background: #f7f8fa;
color: var(--text);
}
.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);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn:disabled:hover {
background: var(--accent);
border-color: var(--accent);
}
</style>
@@ -0,0 +1,481 @@
<script setup lang="ts">
// 주간 타임라인(간트형) — 카테고리=레인, 일정=막대(다중일 span). 가로 스크롤 + 라벨 고정.
import { computed, ref } from 'vue'
import type { ApiScheduleCategory, ApiScheduleEvent } from '@/types/schedule'
import type { WeekDay } from '@/shared/utils/scheduleDate'
const props = defineProps<{
categories: ApiScheduleCategory[]
events: ApiScheduleEvent[]
days: WeekDay[]
activeTypeIds: Set<string>
selId: string | null
}>()
const emit = defineEmits<{
(e: 'select', id: string): void
(e: 'add-category'): void
(e: 'edit-category', id: string): void
(e: 'delete-category', id: string): void
}>()
const COLW = 156
const LABELW = 132
const ROWH = 32
const cols = computed(() => props.days.length)
const colTpl = computed(
() => `${LABELW}px repeat(${cols.value}, minmax(${COLW}px, 1fr))`,
)
const trackCols = computed(
() => `repeat(${cols.value}, minmax(${COLW}px, 1fr))`,
)
const minW = computed(() => LABELW + cols.value * COLW)
// 활성 + 정렬된 레인
const lanes = computed(() =>
[...props.categories]
.filter((c) => props.activeTypeIds.has(c.id))
.sort((a, b) => a.sortOrder - b.sortOrder),
)
interface Cov {
ev: ApiScheduleEvent
startIdx: number
endIdx: number
span: number
row: number
}
// 이벤트가 이번 주 days 안에서 차지하는 시작/끝 인덱스
function coverage(ev: ApiScheduleEvent): Omit<Cov, 'row'> | null {
let startIdx = -1
let endIdx = -1
props.days.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, span: endIdx - startIdx + 1 }
}
// 겹침 회피 그리디 행 배치
function packLane(catId: string): { items: Cov[]; rows: number } {
const cov = props.events
.filter((e) => e.categoryId === catId)
.map(coverage)
.filter((c): c is Omit<Cov, 'row'> => c !== null)
.sort(
(a, b) =>
a.startIdx - b.startIdx || String(a.ev.time).localeCompare(String(b.ev.time)),
)
const rowEnd: number[] = []
const items: Cov[] = cov.map((it) => {
let r = 0
while (r < rowEnd.length && (rowEnd[r] ?? -1) >= it.startIdx) r++
rowEnd[r] = it.endIdx
return { ...it, row: r }
})
return { items, rows: Math.max(rowEnd.length, 1) }
}
const lanesPacked = computed(() =>
lanes.value.map((c) => ({ cat: c, ...packLane(c.id) })),
)
// 카테고리 색 → 막대 음영(약)
function weakOf(color: string): string {
return `color-mix(in srgb, ${color} 13%, #fff)`
}
// 헤더/본문 가로 스크롤 동기화
const headRef = ref<HTMLElement | null>(null)
const bodyRef = ref<HTMLElement | null>(null)
function onBodyScroll(): void {
if (headRef.value && bodyRef.value)
headRef.value.scrollLeft = bodyRef.value.scrollLeft
}
</script>
<template>
<div class="tl">
<!-- 요일 헤더 -->
<div
ref="headRef"
class="tl-head-scroll"
>
<div
class="tl-headrow"
:style="{ gridTemplateColumns: colTpl, minWidth: `${minW}px` }"
>
<div class="tl-corner" />
<div
v-for="d in days"
:key="d.iso"
class="tl-dayhead"
:class="{ 'is-today': d.isToday, weekend: d.weekend }"
>
<span class="sdow">{{ d.dow }}</span>
<span class="sd">{{ d.d }}</span>
</div>
</div>
</div>
<!-- 본문 -->
<div
ref="bodyRef"
class="tl-body-scroll"
@scroll="onBodyScroll"
>
<div
class="tl-body"
:style="{ minWidth: `${minW}px` }"
>
<div
v-if="lanesPacked.length === 0"
class="tl-empty"
>
표시할 카테고리가 없습니다. 유형 필터에서 켜거나 카테고리를 추가하세요.
</div>
<div
v-for="lane in lanesPacked"
:key="lane.cat.id"
class="tl-row"
:style="{ gridTemplateColumns: `${LABELW}px 1fr` }"
>
<!-- 레인 라벨 -->
<div class="tl-label">
<span
class="dot"
:style="{ background: lane.cat.color }"
/>
<span class="ll-name">{{ lane.cat.label }}</span>
<span class="ll-acts">
<button
class="ll-btn"
title="편집"
@click="emit('edit-category', lane.cat.id)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
</button>
<button
class="ll-btn"
title="삭제"
@click="emit('delete-category', lane.cat.id)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M10 11v6M14 11v6" /></svg>
</button>
</span>
</div>
<!-- 트랙 -->
<div class="tl-track">
<div
class="tl-guides"
:style="{ gridTemplateColumns: trackCols }"
>
<div
v-for="d in days"
:key="d.iso"
class="g"
:class="{ 'is-today': d.isToday, weekend: d.weekend }"
/>
</div>
<div
class="tl-bars"
:style="{
gridTemplateColumns: trackCols,
gridTemplateRows: `repeat(${lane.rows}, ${ROWH}px)`,
}"
>
<div
v-for="it in lane.items"
:key="it.ev.id"
class="tl-bar"
:class="{ sel: it.ev.id === selId }"
:style="{
'--bar-color': lane.cat.color,
'--bar-weak': weakOf(lane.cat.color),
gridColumn: `${it.startIdx + 1} / span ${it.span}`,
gridRow: it.row + 1,
}"
@click="emit('select', it.ev.id)"
>
<span class="bt">{{ it.ev.title }}</span>
</div>
</div>
</div>
</div>
<!-- 카테고리 추가 -->
<div class="tl-addrow">
<button
class="tl-addcat"
@click="emit('add-category')"
>
<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>
</div>
</div>
</template>
<style scoped>
.tl {
background: var(--panel);
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow-sm);
}
.tl-head-scroll {
flex-shrink: 0;
overflow: hidden;
}
.tl-body-scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
.tl-body {
display: flex;
flex-direction: column;
}
.tl-headrow {
display: grid;
background: var(--panel);
border-bottom: 1px solid var(--border-strong);
}
.tl-corner {
position: sticky;
left: 0;
z-index: 6;
background: var(--panel);
border-right: 1px solid var(--border);
}
.tl-dayhead {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 9px 8px;
text-align: center;
border-left: 1px solid var(--border);
}
.tl-dayhead .sdow {
font-size: 12px;
font-weight: 600;
color: var(--text-3);
letter-spacing: 0.2px;
}
.tl-dayhead .sd {
display: inline-grid;
place-items: center;
min-width: 26px;
height: 26px;
padding: 0 5px;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.3px;
border-radius: 13px;
}
.tl-dayhead.is-today .sdow {
color: var(--accent);
}
.tl-dayhead.is-today .sd {
background: var(--accent);
color: #fff;
}
.tl-dayhead.weekend {
background: #fafbfc;
}
.tl-dayhead.weekend .sdow,
.tl-dayhead.weekend .sd {
color: var(--text-3);
}
.tl-row {
display: grid;
border-top: 1px solid var(--border);
}
.tl-row:first-of-type {
border-top: none;
}
.tl-label {
display: flex;
align-items: center;
gap: 9px;
padding: 0 8px 0 14px;
font-size: 13.5px;
font-weight: 600;
color: var(--text);
position: sticky;
left: 0;
z-index: 3;
background: var(--panel);
border-right: 1px solid var(--border);
white-space: nowrap;
}
.tl-label .dot {
width: 11px;
height: 11px;
border-radius: 3px;
flex-shrink: 0;
}
.tl-label .ll-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.tl-label .ll-acts {
display: none;
gap: 1px;
flex-shrink: 0;
}
.tl-label:hover .ll-acts {
display: flex;
}
.ll-btn {
width: 25px;
height: 25px;
border: none;
background: transparent;
color: var(--text-3);
border-radius: 5px;
cursor: pointer;
display: grid;
place-items: center;
}
.ll-btn:hover {
background: #eceef1;
color: var(--text);
}
.ll-btn svg {
width: 14px;
height: 14px;
}
.tl-addrow {
border-top: 1px solid var(--border);
}
.tl-addcat {
position: sticky;
left: 0;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 11px 14px;
background: var(--panel);
border: none;
font-family: inherit;
font-size: 13px;
font-weight: 600;
color: var(--text-3);
cursor: pointer;
}
.tl-addcat:hover {
color: var(--accent);
}
.tl-addcat svg {
width: 15px;
height: 15px;
}
.tl-track {
position: relative;
min-width: 0;
}
.tl-guides {
position: absolute;
inset: 0;
display: grid;
}
.tl-guides .g {
border-left: 1px solid var(--border);
}
.tl-guides .g.is-today {
background: color-mix(in srgb, var(--accent) 5%, transparent);
}
.tl-guides .g.weekend {
background: #fafbfc;
}
.tl-bars {
position: relative;
display: grid;
row-gap: 7px;
column-gap: 0;
padding: 9px 0;
}
.tl-bar {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
margin: 0 6px;
padding: 0 11px;
height: 100%;
border-radius: 7px;
background: var(--bar-weak);
border-left: 3px solid var(--bar-color);
cursor: pointer;
overflow: hidden;
transition:
box-shadow 0.12s ease,
transform 0.12s ease;
}
.tl-bar:hover {
box-shadow: 0 3px 10px rgba(20, 24, 33, 0.13);
transform: translateY(-1px);
}
.tl-bar .bt {
font-size: 12.5px;
font-weight: 600;
color: var(--text);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tl-bar.sel {
box-shadow: 0 0 0 2px var(--accent);
}
.tl-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 40px;
color: var(--text-3);
font-size: 13.5px;
text-align: center;
}
</style>
@@ -0,0 +1,385 @@
<script setup lang="ts">
// 유형 필터 + 카테고리 관리 드롭다운
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import type { ApiScheduleCategory } from '@/types/schedule'
const props = defineProps<{
categories: ApiScheduleCategory[]
counts: Record<string, number>
activeTypeIds: Set<string>
}>()
const emit = defineEmits<{
(e: 'toggle', id: string): void
(e: 'set-all', on: boolean): void
(e: 'add'): void
(e: 'edit', id: string): void
(e: 'delete', id: string): void
}>()
const open = ref(false)
const rootRef = ref<HTMLElement | null>(null)
function onDoc(e: MouseEvent): void {
if (rootRef.value && !rootRef.value.contains(e.target as Node)) open.value = false
}
watch(open, (v) => {
if (v) document.addEventListener('mousedown', onDoc)
else document.removeEventListener('mousedown', onDoc)
})
onBeforeUnmount(() => document.removeEventListener('mousedown', onDoc))
const ordered = computed(() =>
[...props.categories].sort((a, b) => a.sortOrder - b.sortOrder),
)
const activeCount = computed(
() => ordered.value.filter((c) => props.activeTypeIds.has(c.id)).length,
)
const allOn = computed(
() => activeCount.value === ordered.value.length && ordered.value.length > 0,
)
</script>
<template>
<div
ref="rootRef"
class="filter"
>
<button
class="btn filter-btn"
:class="{ open }"
@click="open = !open"
>
<span class="fic">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" /></svg>
</span>유형
<span
v-if="!allOn"
class="filter-badge"
>{{ activeCount }}</span>
<span class="chev">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m6 9 6 6 6-6" /></svg>
</span>
</button>
<div
v-if="open"
class="filter-menu"
>
<div class="fm-head">
<span>유형 · 카테고리</span>
<button
class="fm-all"
@click="emit('set-all', !allOn)"
>
{{ allOn ? '전체 해제' : '전체 선택' }}
</button>
</div>
<div class="fm-list">
<div
v-for="c in ordered"
:key="c.id"
class="fm-item"
:class="{ on: activeTypeIds.has(c.id) }"
>
<button
class="fm-tog"
@click="emit('toggle', c.id)"
>
<span class="fm-check">
<svg
v-if="activeTypeIds.has(c.id)"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
</span>
<span
class="dot"
:style="{ background: c.color }"
/>
<span class="fm-label">{{ c.label }}</span>
<span class="fm-ct">{{ counts[c.id] || 0 }}</span>
</button>
<span class="fm-acts">
<button
class="fm-act"
title="편집"
@click="open = false; emit('edit', c.id)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
</button>
<button
class="fm-act"
title="삭제"
@click="open = false; emit('delete', c.id)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M10 11v6M14 11v6" /></svg>
</button>
</span>
</div>
</div>
<button
class="fm-add"
@click="open = false; emit('add')"
>
<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>
</template>
<style scoped>
.filter {
position: relative;
}
.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: 7px;
line-height: 1;
white-space: nowrap;
}
.btn:hover {
background: #f7f8fa;
color: var(--text);
}
.filter-btn .fic {
display: grid;
}
.filter-btn .fic svg {
width: 15px;
height: 15px;
}
.filter-btn .chev {
display: grid;
transition: transform 0.15s ease;
opacity: 0.55;
margin-left: -1px;
}
.filter-btn .chev svg {
width: 14px;
height: 14px;
}
.filter-btn.open {
background: #f1f2f4;
color: var(--text);
border-color: var(--border-strong);
}
.filter-btn.open .chev {
transform: rotate(180deg);
}
.filter-badge {
background: var(--accent);
color: #fff;
font-size: 11px;
font-weight: 700;
border-radius: 9px;
min-width: 18px;
height: 18px;
padding: 0 5px;
display: grid;
place-items: center;
line-height: 1;
}
.filter-menu {
position: absolute;
top: calc(100% + 7px);
right: 0;
z-index: 60;
width: 264px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow-pop);
padding: 6px;
}
.fm-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 8px 9px;
}
.fm-head > span {
font-size: 11px;
font-weight: 700;
color: var(--text-3);
letter-spacing: 0.3px;
white-space: nowrap;
}
.fm-all {
border: none;
background: none;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: var(--accent);
cursor: pointer;
padding: 2px 5px;
border-radius: 4px;
white-space: nowrap;
}
.fm-all:hover {
background: var(--accent-weak);
}
.fm-list {
display: flex;
flex-direction: column;
max-height: 320px;
overflow-y: auto;
}
.fm-item {
display: flex;
align-items: center;
border-radius: 6px;
}
.fm-item:hover {
background: #f4f5f7;
}
.fm-tog {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 9px;
border: none;
background: none;
font-family: inherit;
font-size: 13px;
font-weight: 600;
padding: 8px;
cursor: pointer;
text-align: left;
}
.fm-tog .dot {
width: 10px;
height: 10px;
border-radius: 3px;
flex-shrink: 0;
}
.fm-tog .fm-label {
flex: 1;
min-width: 0;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fm-tog .fm-ct {
font-size: 11px;
font-weight: 700;
color: var(--text-3);
}
.fm-tog .fm-check {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--accent);
display: grid;
place-items: center;
}
.fm-tog .fm-check svg {
width: 15px;
height: 15px;
}
.fm-item:not(.on) .fm-label {
color: var(--text-3);
}
.fm-item:not(.on) .dot {
background: #d7dae0 !important;
}
.fm-acts {
display: none;
gap: 1px;
padding-right: 5px;
}
.fm-item:hover .fm-acts {
display: flex;
}
.fm-act {
width: 25px;
height: 25px;
border: none;
background: transparent;
color: var(--text-3);
border-radius: 5px;
cursor: pointer;
display: grid;
place-items: center;
}
.fm-act:hover {
background: #e3e5e9;
color: var(--text);
}
.fm-act svg {
width: 13px;
height: 13px;
}
.fm-add {
display: flex;
align-items: center;
gap: 7px;
width: 100%;
border: none;
border-top: 1px solid var(--border);
background: none;
font-family: inherit;
font-size: 13px;
font-weight: 600;
color: var(--accent);
padding: 10px 8px 6px;
margin-top: 4px;
cursor: pointer;
}
.fm-add:hover {
color: var(--accent-hover);
}
.fm-add svg {
width: 15px;
height: 15px;
}
</style>
+73
View File
@@ -0,0 +1,73 @@
import { useApi } from './useApi'
import type {
ApiScheduleCategory,
ApiScheduleEvent,
ApiSchedulePerson,
ScheduleCategoryPayload,
ScheduleEventPayload,
} from '@/types/schedule'
// 일정 도메인 API Composable — 인터셉터가 success/data 를 언래핑
export function useSchedule() {
const api = useApi()
// 카테고리
async function listCategories(): Promise<ApiScheduleCategory[]> {
return (await api.get('/schedule/categories')) as unknown as ApiScheduleCategory[]
}
async function createCategory(
payload: ScheduleCategoryPayload,
): Promise<ApiScheduleCategory> {
return (await api.post('/schedule/categories', payload)) as unknown as ApiScheduleCategory
}
async function updateCategory(
id: string,
payload: Partial<ScheduleCategoryPayload>,
): Promise<ApiScheduleCategory> {
return (await api.patch(`/schedule/categories/${id}`, payload)) as unknown as ApiScheduleCategory
}
async function deleteCategory(id: string): Promise<void> {
await api.delete(`/schedule/categories/${id}`)
}
// 참석자 풀
async function listPeople(): Promise<ApiSchedulePerson[]> {
return (await api.get('/schedule/people')) as unknown as ApiSchedulePerson[]
}
// 일정
async function listEvents(
start: string,
end: string,
): Promise<ApiScheduleEvent[]> {
return (await api.get('/schedule/events', {
params: { start, end },
})) as unknown as ApiScheduleEvent[]
}
async function createEvent(
payload: ScheduleEventPayload,
): Promise<ApiScheduleEvent> {
return (await api.post('/schedule/events', payload)) as unknown as ApiScheduleEvent
}
async function updateEvent(
id: string,
payload: Partial<ScheduleEventPayload>,
): Promise<ApiScheduleEvent> {
return (await api.patch(`/schedule/events/${id}`, payload)) as unknown as ApiScheduleEvent
}
async function deleteEvent(id: string): Promise<void> {
await api.delete(`/schedule/events/${id}`)
}
return {
listCategories,
createCategory,
updateCategory,
deleteCategory,
listPeople,
listEvents,
createEvent,
updateEvent,
deleteEvent,
}
}
+30 -3
View File
@@ -29,9 +29,11 @@ function toggleCollapse() {
const mobileOpen = ref(false)
// 현재 경로 기준으로 사이드바 네비 활성 항목을 판별
const activeNav = computed<'tasks' | 'projects'>(() =>
route.path.startsWith('/projects') ? 'projects' : 'tasks',
)
const activeNav = computed<'tasks' | 'projects' | 'schedule'>(() => {
if (route.path.startsWith('/projects')) return 'projects'
if (route.path.startsWith('/schedule')) return 'schedule'
return 'tasks'
})
// 현재 로그인 사용자(프로필 아바타는 UserAvatar 가 이미지/placeholder 로 표시)
const me = computed(() => authStore.user)
@@ -174,6 +176,31 @@ async function onLogout() {
</svg>
<span>프로젝트</span>
</RouterLink>
<RouterLink
to="/schedule"
class="nav-item"
title="일정"
:class="{ active: activeNav === 'schedule' }"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect
x="3"
y="4"
width="18"
height="18"
rx="2"
/>
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
<span>일정</span>
</RouterLink>
</nav>
<!-- 하단: 접기 토글 + 사용자 프로필 + 메뉴(위로 펼침) -->
+355
View File
@@ -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>
+7
View File
@@ -101,6 +101,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/relay/MyTasksPage.vue'),
meta: { requiresAuth: true },
},
{
// 일정 — 전사 공유 주간 캘린더(타임라인)
path: '/schedule',
name: 'schedule',
component: () => import('@/pages/relay/SchedulePage.vue'),
meta: { requiresAuth: true },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
+94
View File
@@ -0,0 +1,94 @@
// 일정(주간 캘린더) 날짜 유틸 — 모두 'YYYY-MM-DD' 로컬 날짜 문자열 기준(시간대 이동 방지).
// 월요일 시작 요일 라벨(월~일)
export const DOW_KO = ['월', '화', '수', '목', '금', '토', '일'] as const
// Date → 로컬 'YYYY-MM-DD'
export function isoOf(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
// 'YYYY-MM-DD' → 로컬 Date(자정)
export function dateOf(iso: string): Date {
const [y, m, d] = iso.split('-').map(Number)
return new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1)
}
// 오늘(로컬) ISO
export function todayIso(): string {
return isoOf(new Date())
}
// iso 가 속한 주의 월요일 ISO
export function mondayOf(iso: string): string {
const dt = dateOf(iso)
// getDay(): 0=일 … 6=토 → 월요일까지 거슬러 올라갈 일수
const back = (dt.getDay() + 6) % 7
dt.setDate(dt.getDate() - back)
return isoOf(dt)
}
// iso + n일
export function addDays(iso: string, n: number): string {
const dt = dateOf(iso)
dt.setDate(dt.getDate() + n)
return isoOf(dt)
}
// 요일 인덱스(0=월 … 6=일)
function dowIndex(iso: string): number {
return (dateOf(iso).getDay() + 6) % 7
}
export interface WeekDay {
iso: string
dow: string
d: number
weekend: boolean
isToday: boolean
}
// 주 시작(월요일) ISO → 7일 배열
export function weekDays(weekStartIso: string): WeekDay[] {
const today = todayIso()
return Array.from({ length: 7 }, (_, i) => {
const iso = addDays(weekStartIso, i)
return {
iso,
dow: DOW_KO[i] ?? '',
d: dateOf(iso).getDate(),
weekend: i >= 5,
isToday: iso === today,
}
})
}
// 주 범위 라벨 — 예: '2026년 6월 22 28일' / 월이 다르면 '6월 29 7월 5일'
export function weekRangeLabel(weekStartIso: string): string {
const s = dateOf(weekStartIso)
const e = dateOf(addDays(weekStartIso, 6))
const sm = s.getMonth() + 1
const em = e.getMonth() + 1
if (sm === em) {
return `${s.getFullYear()}${sm}${s.getDate()} ${e.getDate()}`
}
return `${s.getFullYear()}${sm}${s.getDate()} ${em}${e.getDate()}`
}
// 단일/다중일 표시용 — '6월 22일 (월)' 또는 '6월 22일(월) 24일(수)'
export function eventDateText(start: string, end: string): string {
const s = dateOf(start)
if (start === end) {
return `${s.getMonth() + 1}${s.getDate()}일 (${DOW_KO[dowIndex(start)]})`
}
const e = dateOf(end)
return `${s.getMonth() + 1}${s.getDate()}일(${DOW_KO[dowIndex(start)]}) ${e.getMonth() + 1}${e.getDate()}일(${DOW_KO[dowIndex(end)]})`
}
// 다중일 일수(포함 양끝)
export function spanDays(start: string, end: string): number {
return Math.round((dateOf(end).getTime() - dateOf(start).getTime()) / 86400000) + 1
}
+172
View File
@@ -0,0 +1,172 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { useSchedule } from '@/composables/useSchedule'
import {
addDays,
mondayOf,
todayIso,
weekDays,
weekRangeLabel,
type WeekDay,
} from '@/shared/utils/scheduleDate'
import type {
ApiScheduleCategory,
ApiScheduleEvent,
ApiSchedulePerson,
ScheduleCategoryPayload,
ScheduleEventPayload,
} from '@/types/schedule'
// 일정 스토어 — 카테고리/일정/참석자 + 주간 네비 상태 + CRUD
export const useScheduleStore = defineStore('schedule', () => {
const api = useSchedule()
const categories = ref<ApiScheduleCategory[]>([])
const events = ref<ApiScheduleEvent[]>([])
const people = ref<ApiSchedulePerson[]>([])
// 활성(표시) 카테고리 id 집합 — 유형 필터
const activeTypeIds = ref<Set<string>>(new Set())
// 주 시작(월요일) ISO
const weekStart = ref<string>(mondayOf(todayIso()))
const loading = ref(false)
// 파생: 주간 7일
const days = computed<WeekDay[]>(() => weekDays(weekStart.value))
const rangeLabel = computed(() => weekRangeLabel(weekStart.value))
const weekEnd = computed(() => addDays(weekStart.value, 6))
// 카테고리별 현재 주 일정 수(필터 드롭다운 표기용)
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
return c
})
// --- 로딩 ---
// 최초 진입 — 카테고리/참석자/주간 일정 동시 로드 + 필터 전체 활성화
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),
])
categories.value = cats
people.value = ppl
events.value = evs
activeTypeIds.value = new Set(cats.map((c) => c.id))
} finally {
loading.value = false
}
}
// 현재 주 일정만 재조회
async function reloadEvents(): Promise<void> {
events.value = await api.listEvents(weekStart.value, weekEnd.value)
}
// --- 주간 네비 ---
function goPrevWeek(): void {
weekStart.value = addDays(weekStart.value, -7)
void reloadEvents()
}
function goNextWeek(): void {
weekStart.value = addDays(weekStart.value, 7)
void reloadEvents()
}
function goToday(): void {
weekStart.value = mondayOf(todayIso())
void reloadEvents()
}
// --- 유형 필터 ---
function toggleType(id: string): void {
const n = new Set(activeTypeIds.value)
if (n.has(id)) n.delete(id)
else n.add(id)
activeTypeIds.value = n
}
function setAllTypes(on: boolean): void {
activeTypeIds.value = on
? new Set(categories.value.map((c) => c.id))
: new Set()
}
// --- 일정 CRUD ---
async function createEvent(payload: ScheduleEventPayload): Promise<string> {
const ev = await api.createEvent(payload)
// 활성 필터에 카테고리 포함 보장
if (!activeTypeIds.value.has(ev.categoryId)) toggleType(ev.categoryId)
await reloadEvents()
return ev.id
}
async function updateEvent(
id: string,
payload: Partial<ScheduleEventPayload>,
): Promise<void> {
await api.updateEvent(id, payload)
await reloadEvents()
}
async function deleteEvent(id: string): Promise<void> {
await api.deleteEvent(id)
events.value = events.value.filter((e) => e.id !== id)
}
// --- 카테고리 CRUD ---
async function createCategory(
payload: ScheduleCategoryPayload,
): Promise<void> {
const cat = await api.createCategory(payload)
categories.value = [...categories.value, cat]
toggleTypeOn(cat.id)
}
async function updateCategory(
id: string,
payload: Partial<ScheduleCategoryPayload>,
): Promise<void> {
const cat = await api.updateCategory(id, payload)
categories.value = categories.value.map((c) => (c.id === id ? cat : c))
}
async function deleteCategory(id: string): Promise<void> {
await api.deleteCategory(id)
categories.value = categories.value.filter((c) => c.id !== id)
const n = new Set(activeTypeIds.value)
n.delete(id)
activeTypeIds.value = n
// 포함 일정이 cascade 삭제되었으므로 주간 일정 재조회
await reloadEvents()
}
function toggleTypeOn(id: string): void {
if (!activeTypeIds.value.has(id)) {
activeTypeIds.value = new Set([...activeTypeIds.value, id])
}
}
return {
categories,
events,
people,
activeTypeIds,
weekStart,
loading,
days,
rangeLabel,
counts,
init,
reloadEvents,
goPrevWeek,
goNextWeek,
goToday,
toggleType,
setAllTypes,
createEvent,
updateEvent,
deleteEvent,
createCategory,
updateCategory,
deleteCategory,
}
})
+49
View File
@@ -0,0 +1,49 @@
// 일정(전사 공유 캘린더) 도메인 타입 — 백엔드 Schedule 응답과 1:1
// 카테고리(타임라인 레인)
export interface ApiScheduleCategory {
id: string
label: string
color: string
sortOrder: number
}
// 참석자/사용자(공통 경량 형태)
export interface ApiSchedulePerson {
id: string
name: string
avatarUrl: string | null
}
// 일정(이벤트)
export interface ApiScheduleEvent {
id: string
categoryId: string
title: string
start: string // YYYY-MM-DD
end: string // YYYY-MM-DD
time: string
place: string | null
description: string | null
allHands: boolean
attendees: ApiSchedulePerson[]
createdById: string | null
}
// 생성/수정 payload
export interface ScheduleEventPayload {
title: string
categoryId: string
start: string
end: string
time?: string
place?: string
description?: string
allHands?: boolean
attendeeIds?: string[]
}
export interface ScheduleCategoryPayload {
label: string
color: string
}