feat: 개인 일정 캘린더 추가 및 사내 일정 수정 권한 제한

카테고리를 사내 공용(owner=null)과 개인 전용(owner=사용자)으로 나누고,
일정의 공개 범위를 소속 카테고리에서 파생시켜 두 종류가 섞이지 않게 한다.

- 조회: 사내 일정 + 본인 개인 일정만 반환, 타인의 개인 항목은 존재를 숨김
- 개인 일정은 소유자만 수정·삭제하며 참석자·알림을 사용하지 않음
- 사내 일정은 등록자만 수정·삭제하도록 제한
  (등록자가 탈퇴한 기존 일정은 잠기지 않도록 예외 처리)
- 일정이 남은 사내 카테고리는 삭제를 막아 타인 일정 연쇄 삭제를 방지
- 툴바를 캘린더 선택(사내/개인 체크박스)과 '내 관련' 참석자 필터로 분리
- 일정·카테고리 작성 시 사내/개인 구분을 명시적으로 선택
- 기존 데이터는 전부 사내 공유(company)로 유지되는 마이그레이션 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 23:01:37 +09:00
parent 01132d2ac3
commit f08b9b0c03
13 changed files with 916 additions and 177 deletions
@@ -52,11 +52,18 @@ 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 personal = ref(props.init?.personal ?? false)
const valid = computed(() => !!name.value.trim())
function submit(): void {
if (!valid.value) return
emit('save', { label: name.value.trim(), color: color.value })
const payload: ScheduleCategoryPayload = {
label: name.value.trim(),
color: color.value,
}
if (!isEdit.value) payload.personal = personal.value
emit('save', payload)
}
</script>
@@ -75,6 +82,46 @@ function submit(): void {
@keydown.enter="submit"
>
</label>
<!-- 구분 생성 시에만 선택 가능. 수정 화면에서는 현재 범위를 안내만 한다.
(이후 카테고리에 담기는 일정의 공개 범위가 여기서 결정되므로 색상보다 위에 둔다) -->
<div class="form-field">
<span class="form-label"><span class="req">*</span> 구분</span>
<div
v-if="isEdit"
class="scope-note"
>
{{ personal ? '개인 전용 카테고리입니다. (나만 볼 수 있음)' : '사내 공용 카테고리입니다.' }}
</div>
<template v-else>
<div class="scope-opts">
<button
type="button"
class="scope-opt"
:class="{ on: !personal }"
:aria-pressed="!personal"
@click="personal = false"
>
사내 공용
</button>
<button
type="button"
class="scope-opt"
:class="{ on: personal }"
:aria-pressed="personal"
@click="personal = true"
>
개인 전용
</button>
</div>
<p class="scope-hint">
{{
personal
? '이 카테고리와 여기에 등록한 일정은 나만 볼 수 있습니다.'
: '이 카테고리의 일정은 사내 모든 구성원에게 공개됩니다.'
}}
</p>
</template>
</div>
<div class="form-field">
<span class="form-label">색상</span>
<div class="cat-color">
@@ -117,6 +164,41 @@ function submit(): void {
align-items: center;
gap: 0.75rem;
}
/* 공개 범위 선택 — 2분할 세그먼트 */
.scope-opts {
display: flex;
gap: 0.375rem;
}
.scope-opt {
flex: 1;
height: 2.25rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: #fff;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-2);
cursor: pointer;
}
.scope-opt:hover {
background: #f9fafb;
}
.scope-opt.on {
border-color: var(--accent);
background: var(--accent-weak);
color: var(--accent);
}
.scope-hint {
margin: 0.4375rem 0 0;
font-size: 0.75rem;
line-height: 1.5;
color: var(--text-3);
}
.scope-note {
font-size: 0.8125rem;
color: var(--text-2);
}
.cat-dot {
width: 1.5rem;
height: 1.5rem;
@@ -34,6 +34,9 @@ const dur = computed(() =>
)
const attendees = computed(() => ev.value?.attendees ?? [])
const isPersonal = computed(() => ev.value?.visibility === 'private')
// 수정·삭제 노출 기준 — 서버가 내려준 권한을 그대로 따른다.
const canManage = computed(() => !!ev.value?.canManage)
</script>
<template>
@@ -44,15 +47,37 @@ const attendees = computed(() => ev.value?.attendees ?? [])
<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 />
<div class="dt-chips">
<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-if="isPersonal"
class="private-chip"
title="나만 볼 수 있는 개인 일정"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><rect
x="3"
y="11"
width="18"
height="11"
rx="2"
/><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
개인
</span>
</div>
<button
class="dt-close"
@click="emit('close')"
@@ -124,7 +149,11 @@ const attendees = computed(() => ev.value?.attendees ?? [])
</div>
</div>
<div class="dt-foot">
<!-- 수정·삭제는 권한이 있을 때만 노출(개인 일정=소유자, 전사 일정=등록자) -->
<div
v-if="canManage"
class="dt-foot"
>
<div class="dt-actions">
<button
class="btn"
@@ -179,6 +208,30 @@ const attendees = computed(() => ev.value?.attendees ?? [])
.detail.open {
transform: translateX(0);
}
.dt-chips {
display: flex;
align-items: center;
gap: 0.375rem;
min-width: 0;
}
/* 개인 일정 배지 */
.private-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: 0.375rem;
background: #eef0f4;
color: var(--text-2);
white-space: nowrap;
line-height: 1.6;
}
.private-chip svg {
width: 0.75rem;
height: 0.75rem;
}
.type-chip {
font-size: 0.6875rem;
font-weight: 600;
@@ -15,6 +15,8 @@ const props = defineProps<{
people: ApiSchedulePerson[]
init: ApiScheduleEvent | null
defaultDate: string
// 신규 작성 시 기본 구분 — 현재 보고 있는 캘린더를 따른다
defaultScope?: 'company' | 'personal'
}>()
const emit = defineEmits<{
(e: 'save', payload: ScheduleEventPayload): void
@@ -43,9 +45,34 @@ const pt = parseTime(i?.time ?? '')
const ordered = computed(() =>
[...props.categories].sort((a, b) => a.sortOrder - b.sortOrder),
)
// 카테고리 선택 그룹 — 공개 범위는 카테고리에서 결정된다.
const companyCats = computed(() => ordered.value.filter((c) => !c.personal))
const personalCats = computed(() => ordered.value.filter((c) => c.personal))
// 구분 선택 — 이걸 고르면 아래 유형 목록이 해당 범위로 좁혀진다.
// 수정 시에는 기존 일정의 범위, 신규는 defaultScope(카테고리가 없으면 있는 쪽)로 시작한다.
function initialScope(): 'company' | 'personal' {
if (i) return i.visibility === 'private' ? 'personal' : 'company'
const pref = props.defaultScope ?? 'company'
const prefHasCats =
pref === 'personal' ? personalCats.value.length : companyCats.value.length
if (prefHasCats) return pref
return companyCats.value.length ? 'company' : 'personal'
}
const scope = ref<'company' | 'personal'>(initialScope())
const scopedCats = computed(() =>
scope.value === 'personal' ? personalCats.value : companyCats.value,
)
const title = ref(i?.title ?? '')
const categoryId = ref(i?.categoryId ?? ordered.value[0]?.id ?? '')
const categoryId = ref(i?.categoryId ?? scopedCats.value[0]?.id ?? '')
// 범위를 바꾸면 선택된 유형이 그 범위 밖일 수 있으므로 첫 항목으로 되돌린다.
watch(scope, () => {
if (!scopedCats.value.some((c) => c.id === categoryId.value)) {
categoryId.value = scopedCats.value[0]?.id ?? ''
}
})
const s = ref(i?.start ?? props.defaultDate)
const e = ref(i?.end ?? i?.start ?? props.defaultDate)
const startT = ref(pt.start)
@@ -60,6 +87,9 @@ watch(s, (v) => {
if (e.value < v) e.value = v
})
// 개인 범위 = 개인 일정 — 참석자 개념이 없다.
const isPersonal = computed(() => scope.value === 'personal')
const valid = computed(
() =>
!!title.value.trim() &&
@@ -68,8 +98,8 @@ const valid = computed(
!!e.value &&
e.value >= s.value &&
!!startT.value &&
// 참석자 필수 — 전 직원 또는 1명 이상 선택
(allHands.value || attendeeIds.value.length > 0),
// 참석자 필수 — 전 직원 또는 1명 이상 선택(개인 일정은 해당 없음)
(isPersonal.value || allHands.value || attendeeIds.value.length > 0),
)
// 참석자 — 검색 입력 + 후보 드롭다운 + 선택 칩 (업무 생성의 담당자 등록과 동일 방식)
@@ -124,8 +154,9 @@ function submit(): void {
time,
place: place.value.trim(),
description: desc.value.trim(),
allHands: allHands.value,
attendeeIds: allHands.value ? [] : attendeeIds.value,
// 개인 일정은 서버에서도 비워지지만, 요청 단계에서 먼저 정리해 보낸다.
allHands: isPersonal.value ? false : allHands.value,
attendeeIds: isPersonal.value || allHands.value ? [] : attendeeIds.value,
})
}
</script>
@@ -143,14 +174,38 @@ function submit(): void {
autofocus
>
</label>
<div class="form-field">
<span class="form-label"><span class="req">*</span> 구분</span>
<div class="scope-opts">
<button
type="button"
class="scope-opt"
:class="{ on: scope === 'company' }"
:aria-pressed="scope === 'company'"
@click="scope = 'company'"
>
사내 일정
</button>
<button
type="button"
class="scope-opt"
:class="{ on: scope === 'personal' }"
:aria-pressed="scope === 'personal'"
@click="scope = 'personal'"
>
개인 일정
</button>
</div>
</div>
<label class="form-field"><span class="form-label"><span class="req">*</span> 유형</span>
<div class="form-select-wrap">
<select
v-model="categoryId"
class="form-select"
:disabled="scopedCats.length === 0"
>
<option
v-for="c in ordered"
v-for="c in scopedCats"
:key="c.id"
:value="c.id"
>
@@ -168,6 +223,12 @@ function submit(): void {
><path d="m6 9 6 6 6-6" /></svg>
</span>
</div>
<p
v-if="scopedCats.length === 0"
class="scope-hint"
>
{{ scope === 'personal' ? '개인' : '사내' }} 카테고리가 없습니다. 상단 '유형' 메뉴에서 먼저 카테고리를 추가해 주세요.
</p>
</label>
<div class="form-row">
<label class="form-field"><span class="form-label"><span class="req">*</span> 시작일</span>
@@ -209,7 +270,31 @@ function submit(): void {
placeholder="장소를 입력하세요"
>
</label>
<div class="form-field">
<!-- 개인 일정(개인 카테고리) 나만 보는 일정이라 참석자를 두지 않는다 -->
<div
v-if="isPersonal"
class="private-note"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><rect
x="3"
y="11"
width="18"
height="11"
rx="2"
/><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
개인 일정입니다. 나만 있으며 참석자와 알림은 사용되지 않습니다.
</div>
<div
v-else
class="form-field"
>
<span class="form-label"><span class="req">*</span> 참석자</span>
<div class="combo">
<div class="combo-field">
@@ -330,6 +415,58 @@ function submit(): void {
</template>
<style scoped>
/* 구분(사내/개인) 선택 — 카테고리 모달의 공개 범위 선택과 동일 외형 */
.scope-opts {
display: flex;
gap: 0.375rem;
}
.scope-opt {
flex: 1;
height: 2.25rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: #fff;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-2);
cursor: pointer;
}
.scope-opt:hover {
background: #f9fafb;
}
.scope-opt.on {
border-color: var(--accent);
background: var(--accent-weak);
color: var(--accent);
}
.scope-hint {
margin: 0.4375rem 0 0;
font-size: 0.75rem;
line-height: 1.5;
color: var(--text-3);
}
/* 개인 일정 안내 — 참석자 영역을 대체 */
.private-note {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
font-size: 0.75rem;
line-height: 1.5;
color: var(--text-2);
}
.private-note svg {
width: 0.9375rem;
height: 0.9375rem;
flex-shrink: 0;
color: var(--text-3);
}
/* 시간 입력 행 */
.time-row {
display: flex;
@@ -1,11 +1,13 @@
<script setup lang="ts">
// 유형 필터 + 카테고리 관리 드롭다운
// 유형 필터 + 카테고리 관리 드롭다운 — 사내 공용/내 개인 카테고리를 그룹으로 나눠 표시.
// 상위 캘린더 스위치가 꺼진 그룹은 빈 배열로 전달되어 목록에서 통째로 빠진다.
import { computed, ref } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
import type { ApiScheduleCategory } from '@/types/schedule'
const props = defineProps<{
categories: ApiScheduleCategory[]
companyCategories: ApiScheduleCategory[]
personalCategories: ApiScheduleCategory[]
counts: Record<string, number>
activeTypeIds: Set<string>
}>()
@@ -21,13 +23,20 @@ const open = ref(false)
const rootRef = ref<HTMLElement | null>(null)
useClickOutside(open, () => (open.value = false), [rootRef], { esc: false })
const ordered = computed(() =>
[...props.categories].sort((a, b) => a.sortOrder - b.sortOrder),
// 그룹 렌더링용 — 비어 있는 그룹은 헤더째 숨긴다.
const groups = computed(() =>
[
{ key: 'company', label: '사내', items: props.companyCategories },
{ key: 'personal', label: '내 카테고리', items: props.personalCategories },
].filter((g) => g.items.length > 0),
)
const all = computed(() => [
...props.companyCategories,
...props.personalCategories,
])
const allOn = computed(
() =>
ordered.value.length > 0 &&
ordered.value.every((c) => props.activeTypeIds.has(c.id)),
all.value.length > 0 && all.value.every((c) => props.activeTypeIds.has(c.id)),
)
</script>
@@ -77,65 +86,73 @@ const allOn = computed(
</button>
</div>
<div class="fm-list">
<div
v-for="c in ordered"
:key="c.id"
class="fm-item"
:class="{ on: activeTypeIds.has(c.id) }"
<template
v-for="g in groups"
:key="g.key"
>
<button
class="fm-tog"
@click="emit('toggle', c.id)"
<div class="fm-group">
{{ g.label }}
</div>
<div
v-for="c in g.items"
:key="c.id"
class="fm-item"
:class="{ on: activeTypeIds.has(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>
<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>
<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>
</template>
</div>
<button
class="fm-add"
@@ -251,6 +268,19 @@ const allOn = computed(
max-height: 20rem;
overflow-y: auto;
}
/* 그룹 구분 헤더(사내 / 내 카테고리) */
.fm-group {
padding: 0.4375rem 0.5rem 0.25rem;
font-size: 0.6875rem;
font-weight: 700;
color: var(--text-3);
letter-spacing: 0.0187rem;
}
.fm-group:not(:first-child) {
margin-top: 0.25rem;
border-top: 1px solid var(--border);
padding-top: 0.5rem;
}
.fm-item {
display: flex;
align-items: center;
+129 -10
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
// 일정 — 전사 공유 캘린더(주간 간트 / 월간 달력 그리드 전환)
// 일정 — 전사 공유 캘린더 + 개인 일정(주간 간트 / 월간 달력 그리드 전환)
import { computed, onMounted, ref } from 'vue'
import AppShell from '@/layouts/AppShell.vue'
import ScheduleTimeline from '@/components/schedule/ScheduleTimeline.vue'
@@ -9,7 +9,10 @@ import ScheduleEventModal from '@/components/schedule/ScheduleEventModal.vue'
import ScheduleCategoryModal from '@/components/schedule/ScheduleCategoryModal.vue'
import ScheduleTypeFilter from '@/components/schedule/ScheduleTypeFilter.vue'
import SegmentedTabs from '@/components/common/SegmentedTabs.vue'
import { useScheduleStore, type ScheduleViewMode } from '@/stores/schedule.store'
import {
useScheduleStore,
type ScheduleViewMode,
} from '@/stores/schedule.store'
import { useDialogStore } from '@/stores/dialog.store'
import type {
ApiScheduleEvent,
@@ -48,6 +51,10 @@ const selCategory = computed(
// 새 일정 기본 날짜 — 보고 있는 주의 월요일
const defaultDate = computed(() => store.weekStart)
// 새 일정 기본 구분 — 개인 캘린더만 보고 있으면 개인으로 시작
const defaultScope = computed<'company' | 'personal'>(() =>
!store.showCompany && store.showPersonal ? 'personal' : 'company',
)
// ----- 일정 -----
function openNewEvent(): void {
@@ -93,16 +100,21 @@ 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 msg =
cat.personal && cnt
? `'${cat.label}' 카테고리와 이번 주에 표시된 일정 ${cnt}건을 포함해 해당 카테고리의 모든 일정이 삭제됩니다. 계속할까요?`
: `'${cat.label}' 카테고리를 삭제할까요?`
const ok = await dialog.confirm(msg, {
title: '카테고리 삭제',
confirmText: '삭제',
variant: 'danger',
})
if (!ok) return
await store.deleteCategory(id)
await store.deleteCategory(id).catch(() => {
// 인터셉터가 에러 토스트 처리(예: 일정이 남은 전사 카테고리)
})
}
</script>
@@ -150,11 +162,60 @@ async function onDeleteCategory(id: string): Promise<void> {
@update:model-value="store.setViewMode"
/>
<div class="spacer" />
<!-- 캘린더 선택 사내/개인을 각각 켜고 끈다(겹쳐보기) -->
<div
class="calgroup"
role="group"
aria-label="표시할 캘린더"
>
<button
type="button"
class="calbtn"
:class="{ on: store.showCompany }"
:aria-pressed="store.showCompany"
@click="store.toggleCalendar('company')"
>
<span class="cbox">
<svg
v-if="store.showCompany"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
</span>
사내
</button>
<button
type="button"
class="calbtn"
:class="{ on: store.showPersonal }"
:aria-pressed="store.showPersonal"
@click="store.toggleCalendar('personal')"
>
<span class="cbox">
<svg
v-if="store.showPersonal"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
</span>
개인
</button>
</div>
<!-- 내가 참석자로 지정된 일정만 캘린더 선택과 겹쳐서 적용되는 별도 필터 -->
<button
type="button"
class="mine-btn"
:class="{ on: store.mineOnly }"
:aria-pressed="store.mineOnly"
title="내가 참석자로 지정된 일정만 보기"
@click="store.toggleMineOnly()"
>
<svg
@@ -169,10 +230,11 @@ async function onDeleteCategory(id: string): Promise<void> {
cy="8"
r="4"
/><path d="M6 21v-1a6 6 0 0 1 12 0v1" /></svg>
일정
관련
</button>
<ScheduleTypeFilter
:categories="store.categories"
:company-categories="store.filterCompanyCategories"
:personal-categories="store.filterPersonalCategories"
:counts="store.counts"
:active-type-ids="store.activeTypeIds"
@toggle="store.toggleType"
@@ -201,7 +263,7 @@ async function onDeleteCategory(id: string): Promise<void> {
<div class="viewport">
<ScheduleTimeline
v-if="store.viewMode === 'week'"
:categories="store.categories"
:categories="store.displayCategories"
:events="store.displayEvents"
:days="store.days"
:active-type-ids="store.activeTypeIds"
@@ -240,6 +302,7 @@ async function onDeleteCategory(id: string): Promise<void> {
:people="store.people"
:init="eventModal.init"
:default-date="defaultDate"
:default-scope="defaultScope"
@save="onSaveEvent"
@close="eventModal = null"
/>
@@ -318,7 +381,63 @@ async function onDeleteCategory(id: string): Promise<void> {
.spacer {
flex: 1;
}
/* '내 일정' 토글 — 정렬/필터 pill 과 동일 외형, 활성 시 강조색 */
/* 캘린더 선택 — 두 체크박스를 한 덩어리(연결된 pill)로 묶는다 */
.calgroup {
display: flex;
}
.calgroup .calbtn:first-child {
border-radius: var(--radius) 0 0 var(--radius);
}
.calgroup .calbtn:last-child {
border-radius: 0 var(--radius) var(--radius) 0;
margin-left: -1px;
}
.calbtn {
display: inline-flex;
align-items: center;
gap: 0.4375rem;
height: 2.25rem;
padding: 0 0.75rem;
border: 1px solid var(--border-strong);
background: #fff;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-3);
cursor: pointer;
white-space: nowrap;
}
.calbtn:hover {
background: #f9fafb;
z-index: 1;
}
.calbtn.on {
border-color: var(--accent);
background: var(--accent-weak);
color: var(--accent);
z-index: 1;
}
/* 체크 박스 — 켜짐만 채워진 사각형 */
.calbtn .cbox {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
border: 1px solid var(--border-strong);
border-radius: 0.1875rem;
background: #fff;
display: grid;
place-items: center;
}
.calbtn.on .cbox {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
.calbtn .cbox svg {
width: 0.625rem;
height: 0.625rem;
}
/* '내 관련' 토글 — 캘린더 선택과 독립적으로 겹쳐 적용되는 참석자 필터 */
.mine-btn {
display: inline-flex;
align-items: center;
+64 -8
View File
@@ -25,6 +25,9 @@ import type {
export type ScheduleViewMode = 'week' | 'month'
// 캘린더 종류 — 사내 공유 / 내 개인. 두 캘린더는 각각 켜고 끌 수 있다(겹쳐보기).
export type ScheduleCalendar = 'company' | 'personal'
// 일정 스토어 — 카테고리/일정/참석자 + 주간/월간 네비 상태 + CRUD
export const useScheduleStore = defineStore('schedule', () => {
const api = useSchedule()
@@ -33,9 +36,12 @@ export const useScheduleStore = defineStore('schedule', () => {
const categories = ref<ApiScheduleCategory[]>([])
const events = ref<ApiScheduleEvent[]>([])
const people = ref<ApiSchedulePerson[]>([])
// 활성(표시) 카테고리 id 집합 — 유형 필터
// 활성(표시) 카테고리 id 집합 — 유형 필터(캘린더 안에서 카테고리 단위로 걸러낸다)
const activeTypeIds = ref<Set<string>>(new Set())
// '내 일정만' 보기 — 참석자/전직원/작성자 기준
// 표시할 캘린더 — 상위 스위치. 꺼진 캘린더는 유형 필터 목록에서도 그룹째 빠진다.
const showCompany = ref(true)
const showPersonal = ref(true)
// '내 관련'만 보기 — 캘린더 선택과 무관하게 겹쳐서 적용되는 참석자 기준 필터
const mineOnly = ref(false)
// 보기 모드(주간/월간) + 기준 날짜(이 날짜가 속한 주/월을 표시)
const viewMode = ref<ScheduleViewMode>('week')
@@ -68,17 +74,53 @@ export const useScheduleStore = defineStore('schedule', () => {
: monthLabel(anchor.value),
)
// '내 일정' 판정 — 참석자 기준(전 직원 대상은 전원 포함이므로 내 일정에 포함)
const myId = computed(() => authStore.user?.id ?? null)
// 개인 일정 판정 — 서버가 내 것만 내려주므로 공개 범위만 보면 된다.
function isPersonal(e: ApiScheduleEvent): boolean {
return e.visibility === 'private'
}
// 켜져 있는 캘린더의 일정인지
function inActiveCalendar(e: ApiScheduleEvent): boolean {
return isPersonal(e) ? showPersonal.value : showCompany.value
}
// '내 관련' 판정 — 내가 참석자로 지정된 일정(전 직원 대상 포함).
// 개인 일정은 애초에 내 것뿐이므로 항상 해당된다.
function isMine(e: ApiScheduleEvent): boolean {
if (isPersonal(e)) return true
if (!myId.value) return false
return e.allHands || e.attendees.some((a) => a.id === myId.value)
}
// 화면 표시 대상 일정 — '내 일정만' 토글 적용(카테고리 필터는 뷰 컴포넌트가 처리)
// 화면 표시 대상 일정 — 캘린더 선택과 '내 관련' 필터를 겹쳐서 적용한다.
// (카테고리 단위 필터는 뷰 컴포넌트가 activeTypeIds 로 처리)
const displayEvents = computed(() =>
mineOnly.value ? events.value.filter(isMine) : events.value,
events.value.filter(
(e) => inActiveCalendar(e) && (!mineOnly.value || isMine(e)),
),
)
// 카테고리 그룹 — 필터/선택 UI 에서 사내 공용과 내 개인을 나눠 보여준다.
const sortedCategories = computed(() =>
[...categories.value].sort((a, b) => a.sortOrder - b.sortOrder),
)
const companyCategories = computed(() =>
sortedCategories.value.filter((c) => !c.personal),
)
const personalCategories = computed(() =>
sortedCategories.value.filter((c) => c.personal),
)
// 유형 필터에 노출할 카테고리 — 꺼진 캘린더는 그룹째 숨겨 캘린더 스위치와 역할이 겹치지 않게 한다.
const filterCompanyCategories = computed(() =>
showCompany.value ? companyCategories.value : [],
)
const filterPersonalCategories = computed(() =>
showPersonal.value ? personalCategories.value : [],
)
// 주간 뷰 레인 — 꺼진 캘린더의 레인은 빈 줄로 남지 않도록 제외한다.
const displayCategories = computed(() => [
...filterCompanyCategories.value,
...filterPersonalCategories.value,
])
// 카테고리별 현재 범위 일정 수(필터 드롭다운 표기용) — 표시 대상 기준
const counts = computed<Record<string, number>>(() => {
const c: Record<string, number> = {}
@@ -144,10 +186,18 @@ export const useScheduleStore = defineStore('schedule', () => {
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()
const next = new Set(activeTypeIds.value)
for (const c of displayCategories.value) {
if (on) next.add(c.id)
else next.delete(c.id)
}
activeTypeIds.value = next
}
function toggleCalendar(kind: ScheduleCalendar): void {
if (kind === 'company') showCompany.value = !showCompany.value
else showPersonal.value = !showPersonal.value
}
function toggleMineOnly(): void {
mineOnly.value = !mineOnly.value
@@ -206,10 +256,15 @@ export const useScheduleStore = defineStore('schedule', () => {
return {
categories,
filterCompanyCategories,
filterPersonalCategories,
displayCategories,
events,
displayEvents,
people,
activeTypeIds,
showCompany,
showPersonal,
mineOnly,
viewMode,
weekStart,
@@ -226,6 +281,7 @@ export const useScheduleStore = defineStore('schedule', () => {
goToday,
toggleType,
setAllTypes,
toggleCalendar,
toggleMineOnly,
createEvent,
updateEvent,
+12 -2
View File
@@ -1,11 +1,15 @@
// 일정(전사 공유 캘린더) 도메인 타입 — 백엔드 Schedule 응답과 1:1
// 일정(전사 공유 캘린더 + 개인 일정) 도메인 타입 — 백엔드 Schedule 응답과 1:1
// 카테고리(타임라인 레인)
// 일정 공개 범위 — company: 전사 공유, private: 본인만
export type ScheduleVisibility = 'company' | 'private'
// 카테고리(타임라인 레인) — personal 이면 본인만 보는 개인 카테고리
export interface ApiScheduleCategory {
id: string
label: string
color: string
sortOrder: number
personal: boolean
}
// 참석자/사용자(공통 경량 형태)
@@ -28,6 +32,10 @@ export interface ApiScheduleEvent {
allHands: boolean
attendees: ApiSchedulePerson[]
createdById: string | null
visibility: ScheduleVisibility
ownerId: string | null
// 서버가 판정한 수정·삭제 권한 — 개인 일정은 소유자, 전사 일정은 작성자
canManage: boolean
}
// 생성/수정 payload
@@ -46,4 +54,6 @@ export interface ScheduleEventPayload {
export interface ScheduleCategoryPayload {
label: string
color: string
// 개인 전용 카테고리 여부 — 생성 시에만 유효(수정으로 전환 불가)
personal?: boolean
}