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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user