feat: 일정 — 내 일정 필터 + 월간 '+N' 팝오버 + 필수표기/참석자 필수

- '내 일정' 토글 추가(참석자 기준: 전 직원 또는 참석자에 본인 포함). 주간/월간 공통
- 월간 '+N' 클릭 시 그날 전체 일정 팝오버(Teleport, 바깥클릭/ESC 닫기, 클릭→상세)
- 일정/카테고리 모달 필수 입력 표기(*) — 제목·유형·시작일·종료일·시간·참석자, 카테고리 이름
  (.form-label .req 전역 스타일 추가)
- 참석자 필수화(폼 검증: 전 직원 또는 1명 이상)
- 월간 일정바 두께 소폭 증가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 13:33:52 +09:00
parent 4ff746638c
commit 511efec64d
6 changed files with 264 additions and 29 deletions
+5
View File
@@ -582,6 +582,11 @@ body {
font-weight: 600;
color: var(--text-2);
}
/* 필수 입력 표시(*) — 업무/프로젝트 작성 폼과 동일 */
.form-label .req {
color: var(--accent);
font-weight: 700;
}
.form-row {
display: flex;
gap: 0.75rem;
@@ -66,7 +66,7 @@ function submit(): void {
size="sm"
@close="emit('close')"
>
<label class="form-field"><span class="form-label">이름</span>
<label class="form-field"><span class="form-label"><span class="req">*</span> 이름</span>
<input
v-model="name"
class="form-input"
@@ -67,7 +67,9 @@ const valid = computed(
!!s.value &&
!!e.value &&
e.value >= s.value &&
!!startT.value,
!!startT.value &&
// 참석자 필수 — 전 직원 또는 1명 이상 선택
(allHands.value || attendeeIds.value.length > 0),
)
// 참석자 — 검색 입력 + 후보 드롭다운 + 선택 칩 (업무 생성의 담당자 등록과 동일 방식)
@@ -133,7 +135,7 @@ function submit(): void {
:title="isEdit ? '일정 수정' : '새 일정'"
@close="emit('close')"
>
<label class="form-field"><span class="form-label">제목</span>
<label class="form-field"><span class="form-label"><span class="req">*</span> 제목</span>
<input
v-model="title"
class="form-input"
@@ -141,7 +143,7 @@ function submit(): void {
autofocus
>
</label>
<label class="form-field"><span class="form-label">유형</span>
<label class="form-field"><span class="form-label"><span class="req">*</span> 유형</span>
<div class="form-select-wrap">
<select
v-model="categoryId"
@@ -168,14 +170,14 @@ function submit(): void {
</div>
</label>
<div class="form-row">
<label class="form-field"><span class="form-label">시작일</span>
<label class="form-field"><span class="form-label"><span class="req">*</span> 시작일</span>
<input
v-model="s"
type="date"
class="form-input"
>
</label>
<label class="form-field"><span class="form-label">종료일</span>
<label class="form-field"><span class="form-label"><span class="req">*</span> 종료일</span>
<input
v-model="e"
type="date"
@@ -185,7 +187,7 @@ function submit(): void {
</label>
</div>
<div class="form-field">
<span class="form-label">시간</span>
<span class="form-label"><span class="req">*</span> 시간</span>
<div class="time-row">
<input
v-model="startT"
@@ -208,7 +210,7 @@ function submit(): void {
>
</label>
<div class="form-field">
<span class="form-label">참석자</span>
<span class="form-label"><span class="req">*</span> 참석자</span>
<div class="combo">
<div class="combo-field">
<input
@@ -1,7 +1,9 @@
<script setup lang="ts">
// 월간 달력 그리드 — 주(행) × 요일(열). 일정은 주 단위로 다중일 막대(span)로 배치하고,
// 한 주에서 보이는 레인을 초과하면 해당 날짜 칸에 '+N' 으로 표기. 카테고리 색·유형 필터 반영.
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
import { eventDateText } from '@/shared/utils/scheduleDate'
import type { ApiScheduleCategory, ApiScheduleEvent } from '@/types/schedule'
import type { MonthDay } from '@/shared/utils/scheduleDate'
@@ -36,10 +38,15 @@ interface MBar {
span: number
lane: number
}
interface MoreMark {
col: number
iso: string
n: number
}
interface LaidWeek {
week: MonthDay[]
items: MBar[]
moreByDay: number[]
mores: MoreMark[]
}
// 주별로 일정을 클립(해당 주 범위)·정렬·레인 패킹
@@ -87,9 +94,42 @@ const laidWeeks = computed<LaidWeek[]>(() =>
moreByDay[d] = (moreByDay[d] ?? 0) + 1
}
}
return { week, items, moreByDay }
const mores: MoreMark[] = moreByDay
.map((n, i) => ({ col: i + 1, iso: week[i]?.iso ?? '', n }))
.filter((m) => m.n > 0)
return { week, items, mores }
}),
)
// '+N' 클릭 → 그날 전체 일정 팝오버
const dayPopover = ref<{ iso: string; top: number; left: number } | null>(null)
const popoverRef = ref<HTMLElement | null>(null)
const popoverOpen = computed(() => !!dayPopover.value)
useClickOutside(popoverOpen, () => (dayPopover.value = null), [popoverRef], {
event: 'mousedown',
esc: true,
closeOnScroll: true,
})
function openDay(iso: string, e: MouseEvent): void {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
const width = 240
const left = Math.max(8, Math.min(r.left, window.innerWidth - width - 8))
dayPopover.value = { iso, top: r.bottom + 4, left }
}
const popoverEvents = computed(() => {
const iso = dayPopover.value?.iso
if (!iso) return []
return visibleEvents.value
.filter((e) => e.start <= iso && e.end >= iso)
.sort((a, b) => String(a.time).localeCompare(String(b.time)))
})
function dayLabel(iso: string): string {
return eventDateText(iso, iso)
}
function pickEvent(id: string): void {
emit('select', id)
dayPopover.value = null
}
</script>
<template>
@@ -145,21 +185,57 @@ const laidWeeks = computed<LaidWeek[]>(() =>
>
<span class="mg-bt">{{ b.ev.title }}</span>
</button>
<!-- 초과(+N) -->
<template
v-for="(n, di) in wk.moreByDay"
:key="'m' + di"
<!-- 초과(+N) 클릭 그날 전체 일정 팝오버 -->
<button
v-for="m in wk.mores"
:key="'m' + m.iso"
type="button"
class="mg-more"
:style="{ gridColumn: m.col, gridRow: VISIBLE_LANES + 2 }"
@click.stop="openDay(m.iso, $event)"
>
<div
v-if="n > 0"
class="mg-more"
:style="{ gridColumn: di + 1, gridRow: VISIBLE_LANES + 2 }"
>
+{{ n }}
</div>
</template>
+{{ m.n }}
</button>
</div>
</div>
<!-- 날짜별 전체 일정 팝오버 -->
<Teleport to="body">
<div
v-if="dayPopover"
ref="popoverRef"
class="mg-pop"
:style="{ top: dayPopover.top + 'px', left: dayPopover.left + 'px' }"
>
<div class="mg-pop-h">
{{ dayLabel(dayPopover.iso) }}
</div>
<button
v-for="e in popoverEvents"
:key="e.id"
type="button"
class="mg-pop-row"
:class="{ sel: e.id === selId }"
@click="pickEvent(e.id)"
>
<span
class="mg-pop-dot"
:style="{ background: colorOf(e.categoryId) }"
/>
<span
v-if="e.time"
class="mg-pop-tm"
>{{ e.time }}</span>
<span class="mg-pop-tt">{{ e.title }}</span>
</button>
<div
v-if="popoverEvents.length === 0"
class="mg-pop-empty"
>
일정이 없습니다.
</div>
</div>
</Teleport>
</div>
</template>
@@ -208,7 +284,7 @@ const laidWeeks = computed<LaidWeek[]>(() =>
min-height: 6rem;
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: 1.625rem repeat(3, 1.375rem) 1fr;
grid-template-rows: 1.625rem repeat(3, 1.55rem) 1fr;
row-gap: 0.125rem;
border-top: 1px solid var(--border);
}
@@ -259,7 +335,7 @@ const laidWeeks = computed<LaidWeek[]>(() =>
z-index: 1;
margin: 0 0.25rem;
padding: 0 0.4375rem;
height: 1.25rem;
height: 1.45rem;
display: flex;
align-items: center;
border: none;
@@ -287,9 +363,85 @@ const laidWeeks = computed<LaidWeek[]>(() =>
.mg-more {
z-index: 1;
align-self: start;
justify-self: start;
margin: 0 0.4375rem;
padding: 0.0625rem 0.25rem;
border: none;
background: transparent;
border-radius: 0.25rem;
font-family: inherit;
font-size: 0.6875rem;
font-weight: 700;
color: var(--text-3);
cursor: pointer;
}
.mg-more:hover {
background: #eceef1;
color: var(--text-2);
}
/* 날짜별 전체 일정 팝오버 */
.mg-pop {
position: fixed;
z-index: 1000;
width: 15rem;
max-height: 18rem;
overflow-y: auto;
background: #fff;
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-pop);
padding: 0.375rem;
}
.mg-pop-h {
padding: 0.375rem 0.5rem 0.5rem;
font-size: 0.75rem;
font-weight: 700;
color: var(--text-3);
}
.mg-pop-row {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.4375rem 0.5rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
text-align: left;
}
.mg-pop-row:hover {
background: #f5f6f8;
}
.mg-pop-row.sel {
background: var(--accent-weak);
}
.mg-pop-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
}
.mg-pop-tm {
font-size: 0.719rem;
font-weight: 600;
color: var(--text-3);
flex-shrink: 0;
}
.mg-pop-tt {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mg-pop-empty {
padding: 0.625rem 0.5rem;
font-size: 0.75rem;
color: var(--text-3);
text-align: center;
}
</style>
+56 -2
View File
@@ -150,6 +150,27 @@ async function onDeleteCategory(id: string): Promise<void> {
@update:model-value="store.setViewMode"
/>
<div class="spacer" />
<button
type="button"
class="mine-btn"
:class="{ on: store.mineOnly }"
:aria-pressed="store.mineOnly"
@click="store.toggleMineOnly()"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><circle
cx="12"
cy="8"
r="4"
/><path d="M6 21v-1a6 6 0 0 1 12 0v1" /></svg>
일정
</button>
<ScheduleTypeFilter
:categories="store.categories"
:counts="store.counts"
@@ -181,7 +202,7 @@ async function onDeleteCategory(id: string): Promise<void> {
<ScheduleTimeline
v-if="store.viewMode === 'week'"
:categories="store.categories"
:events="store.events"
:events="store.displayEvents"
:days="store.days"
:active-type-ids="store.activeTypeIds"
:sel-id="selId"
@@ -191,7 +212,7 @@ async function onDeleteCategory(id: string): Promise<void> {
<ScheduleMonthGrid
v-else
:weeks="store.monthWeeks"
:events="store.events"
:events="store.displayEvents"
:categories="store.categories"
:active-type-ids="store.activeTypeIds"
:sel-id="selId"
@@ -297,6 +318,39 @@ async function onDeleteCategory(id: string): Promise<void> {
.spacer {
flex: 1;
}
/* '내 일정' 토글 — 정렬/필터 pill 과 동일 외형, 활성 시 강조색 */
.mine-btn {
display: inline-flex;
align-items: center;
gap: 0.4375rem;
height: 2.25rem;
padding: 0 0.75rem;
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;
white-space: nowrap;
}
.mine-btn:hover {
background: #f9fafb;
}
.mine-btn svg {
width: 0.9375rem;
height: 0.9375rem;
color: var(--text-3);
}
.mine-btn.on {
border-color: var(--accent);
background: var(--accent-weak);
color: var(--accent);
}
.mine-btn.on svg {
color: var(--accent);
}
/* '새 일정' 버튼은 전역 .btn / .btn.primary 표준(다른 화면의 주요 버튼과 동일)을 그대로 사용 */
.content-row {
flex: 1;
+24 -2
View File
@@ -1,6 +1,7 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { useSchedule } from '@/composables/useSchedule'
import { useAuthStore } from './auth.store'
import {
addDays,
addMonths,
@@ -27,12 +28,15 @@ export type ScheduleViewMode = 'week' | 'month'
// 일정 스토어 — 카테고리/일정/참석자 + 주간/월간 네비 상태 + CRUD
export const useScheduleStore = defineStore('schedule', () => {
const api = useSchedule()
const authStore = useAuthStore()
const categories = ref<ApiScheduleCategory[]>([])
const events = ref<ApiScheduleEvent[]>([])
const people = ref<ApiSchedulePerson[]>([])
// 활성(표시) 카테고리 id 집합 — 유형 필터
const activeTypeIds = ref<Set<string>>(new Set())
// '내 일정만' 보기 — 참석자/전직원/작성자 기준
const mineOnly = ref(false)
// 보기 모드(주간/월간) + 기준 날짜(이 날짜가 속한 주/월을 표시)
const viewMode = ref<ScheduleViewMode>('week')
const anchor = ref<string>(todayIso())
@@ -64,10 +68,22 @@ export const useScheduleStore = defineStore('schedule', () => {
: monthLabel(anchor.value),
)
// 카테고리별 현재 범위 일정 수(필터 드롭다운 표기용)
// '내 일정' 판정 — 참석자 기준(전 직원 대상은 전원 포함이므로 내 일정에 포함)
const myId = computed(() => authStore.user?.id ?? null)
function isMine(e: ApiScheduleEvent): boolean {
if (!myId.value) return false
return e.allHands || e.attendees.some((a) => a.id === myId.value)
}
// 화면 표시 대상 일정 — '내 일정만' 토글 적용(카테고리 필터는 뷰 컴포넌트가 처리)
const displayEvents = computed(() =>
mineOnly.value ? events.value.filter(isMine) : events.value,
)
// 카테고리별 현재 범위 일정 수(필터 드롭다운 표기용) — 표시 대상 기준
const counts = computed<Record<string, number>>(() => {
const c: Record<string, number> = {}
for (const e of events.value) c[e.categoryId] = (c[e.categoryId] || 0) + 1
for (const e of displayEvents.value)
c[e.categoryId] = (c[e.categoryId] || 0) + 1
return c
})
@@ -133,6 +149,9 @@ export const useScheduleStore = defineStore('schedule', () => {
? new Set(categories.value.map((c) => c.id))
: new Set()
}
function toggleMineOnly(): void {
mineOnly.value = !mineOnly.value
}
// --- 일정 CRUD ---
async function createEvent(payload: ScheduleEventPayload): Promise<string> {
@@ -188,8 +207,10 @@ export const useScheduleStore = defineStore('schedule', () => {
return {
categories,
events,
displayEvents,
people,
activeTypeIds,
mineOnly,
viewMode,
weekStart,
loading,
@@ -205,6 +226,7 @@ export const useScheduleStore = defineStore('schedule', () => {
goToday,
toggleType,
setAllTypes,
toggleMineOnly,
createEvent,
updateEvent,
deleteEvent,