Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a344945e4e | |||
| 34b5b1d552 | |||
| ad950e88ad | |||
| 511efec64d | |||
| 4ff746638c |
@@ -210,7 +210,7 @@ export class AuthController {
|
||||
): Promise<void> {
|
||||
// 소셜 로그인은 별도 체크박스가 없어 로그인 유지(영속)로 처리
|
||||
await this.loginWith(res, req.user as PublicUser, true);
|
||||
res.redirect(`${this.webUrl()}/projects`);
|
||||
res.redirect(`${this.webUrl()}/schedule`);
|
||||
}
|
||||
|
||||
// ── 카카오 OAuth ──
|
||||
@@ -232,7 +232,7 @@ export class AuthController {
|
||||
): Promise<void> {
|
||||
// 소셜 로그인은 별도 체크박스가 없어 로그인 유지(영속)로 처리
|
||||
await this.loginWith(res, req.user as PublicUser, true);
|
||||
res.redirect(`${this.webUrl()}/projects`);
|
||||
res.redirect(`${this.webUrl()}/schedule`);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
<script setup lang="ts">
|
||||
// 월간 달력 그리드 — 주(행) × 요일(열). 일정은 주 단위로 다중일 막대(span)로 배치하고,
|
||||
// 한 주에서 보이는 레인을 초과하면 해당 날짜 칸에 '+N' 으로 표기. 카테고리 색·유형 필터 반영.
|
||||
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'
|
||||
|
||||
const props = defineProps<{
|
||||
weeks: MonthDay[][]
|
||||
events: ApiScheduleEvent[]
|
||||
categories: ApiScheduleCategory[]
|
||||
activeTypeIds: Set<string>
|
||||
selId: string | null
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'select', id: string): void }>()
|
||||
|
||||
const WEEKDAYS = ['월', '화', '수', '목', '금', '토', '일']
|
||||
// 한 주에서 막대로 보일 최대 레인 수(초과분은 '+N')
|
||||
const VISIBLE_LANES = 3
|
||||
|
||||
const colorMap = computed(() => {
|
||||
const m: Record<string, string> = {}
|
||||
for (const c of props.categories) m[c.id] = c.color
|
||||
return m
|
||||
})
|
||||
const colorOf = (catId: string): string => colorMap.value[catId] ?? '#9298a3'
|
||||
const weakOf = (color: string): string => `color-mix(in srgb, ${color} 13%, #fff)`
|
||||
|
||||
const visibleEvents = computed(() =>
|
||||
props.events.filter((e) => props.activeTypeIds.has(e.categoryId)),
|
||||
)
|
||||
|
||||
interface MBar {
|
||||
ev: ApiScheduleEvent
|
||||
startIdx: number
|
||||
span: number
|
||||
lane: number
|
||||
}
|
||||
interface MoreMark {
|
||||
col: number
|
||||
iso: string
|
||||
n: number
|
||||
}
|
||||
interface LaidWeek {
|
||||
week: MonthDay[]
|
||||
items: MBar[]
|
||||
mores: MoreMark[]
|
||||
}
|
||||
|
||||
// 주별로 일정을 클립(해당 주 범위)·정렬·레인 패킹
|
||||
const laidWeeks = computed<LaidWeek[]>(() =>
|
||||
props.weeks.map((week) => {
|
||||
const ws = week[0]?.iso ?? ''
|
||||
const we = week[6]?.iso ?? ''
|
||||
const cov = visibleEvents.value
|
||||
.map((ev) => {
|
||||
if (ev.end < ws || ev.start > we) return null
|
||||
let startIdx = -1
|
||||
let endIdx = -1
|
||||
week.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 }
|
||||
})
|
||||
.filter((c): c is { ev: ApiScheduleEvent; startIdx: number; endIdx: number } => c !== null)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.startIdx - b.startIdx ||
|
||||
String(a.ev.time).localeCompare(String(b.ev.time)),
|
||||
)
|
||||
|
||||
const rowEnd: number[] = []
|
||||
const items: MBar[] = []
|
||||
const moreByDay = [0, 0, 0, 0, 0, 0, 0]
|
||||
for (const it of cov) {
|
||||
let r = 0
|
||||
while (r < rowEnd.length && (rowEnd[r] ?? -1) >= it.startIdx) r++
|
||||
rowEnd[r] = it.endIdx
|
||||
if (r < VISIBLE_LANES) {
|
||||
items.push({
|
||||
ev: it.ev,
|
||||
startIdx: it.startIdx,
|
||||
span: it.endIdx - it.startIdx + 1,
|
||||
lane: r,
|
||||
})
|
||||
} else {
|
||||
for (let d = it.startIdx; d <= it.endIdx; d++)
|
||||
moreByDay[d] = (moreByDay[d] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
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>
|
||||
<div class="mg">
|
||||
<div class="mg-head">
|
||||
<div
|
||||
v-for="(d, i) in WEEKDAYS"
|
||||
:key="d"
|
||||
class="mg-hd"
|
||||
:class="{ weekend: i >= 5 }"
|
||||
>
|
||||
{{ d }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mg-body">
|
||||
<div
|
||||
v-for="(wk, wi) in laidWeeks"
|
||||
:key="wi"
|
||||
class="mg-week"
|
||||
>
|
||||
<!-- 날짜 칸 배경(테두리/오늘/주말/이전·다음달) -->
|
||||
<div
|
||||
v-for="(d, di) in wk.week"
|
||||
:key="'bg' + d.iso"
|
||||
class="mg-bg"
|
||||
:class="{ today: d.isToday, weekend: d.weekend, outside: !d.inMonth }"
|
||||
:style="{ gridColumn: di + 1, gridRow: '1 / -1' }"
|
||||
/>
|
||||
<!-- 날짜 숫자 -->
|
||||
<div
|
||||
v-for="(d, di) in wk.week"
|
||||
:key="'n' + d.iso"
|
||||
class="mg-dnum"
|
||||
:class="{ today: d.isToday, outside: !d.inMonth }"
|
||||
:style="{ gridColumn: di + 1, gridRow: 1 }"
|
||||
>
|
||||
<span class="mg-dn">{{ d.d }}</span>
|
||||
</div>
|
||||
<!-- 일정 막대 -->
|
||||
<button
|
||||
v-for="b in wk.items"
|
||||
:key="b.ev.id"
|
||||
type="button"
|
||||
class="mg-bar"
|
||||
:class="{ sel: b.ev.id === selId }"
|
||||
:style="{
|
||||
gridColumn: `${b.startIdx + 1} / span ${b.span}`,
|
||||
gridRow: b.lane + 2,
|
||||
'--c': colorOf(b.ev.categoryId),
|
||||
'--w': weakOf(colorOf(b.ev.categoryId)),
|
||||
}"
|
||||
@click="emit('select', b.ev.id)"
|
||||
>
|
||||
<span class="mg-bt">{{ b.ev.title }}</span>
|
||||
</button>
|
||||
<!-- 초과(+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)"
|
||||
>
|
||||
+{{ 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>
|
||||
|
||||
<style scoped>
|
||||
.mg {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.mg-head {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mg-hd {
|
||||
padding: 0.5rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.mg-hd:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
.mg-hd.weekend {
|
||||
color: var(--text-3);
|
||||
background: #fafbfc;
|
||||
}
|
||||
.mg-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mg-week {
|
||||
flex: 1;
|
||||
min-height: 6rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-rows: 1.625rem repeat(3, 1.55rem) 1fr;
|
||||
row-gap: 0.125rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.mg-week:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.mg-bg {
|
||||
border-left: 1px solid var(--border);
|
||||
min-width: 0;
|
||||
}
|
||||
.mg-bg:nth-child(7n + 1) {
|
||||
border-left: none;
|
||||
}
|
||||
.mg-bg.weekend {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.mg-bg.outside {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
.mg-bg.today {
|
||||
background: color-mix(in srgb, var(--accent) 6%, #fff);
|
||||
}
|
||||
.mg-dnum {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0.25rem 0.4375rem 0;
|
||||
}
|
||||
.mg-dn {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
.mg-dnum.outside .mg-dn {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.mg-dnum.today .mg-dn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.mg-bar {
|
||||
z-index: 1;
|
||||
margin: 0 0.25rem;
|
||||
padding: 0 0.4375rem;
|
||||
height: 1.45rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-left: 3px solid var(--c);
|
||||
border-radius: 0.3125rem;
|
||||
background: var(--w);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
font-family: inherit;
|
||||
}
|
||||
.mg-bar:hover {
|
||||
filter: brightness(0.97);
|
||||
}
|
||||
.mg-bar.sel {
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
.mg-bt {
|
||||
font-size: 0.719rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.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>
|
||||
@@ -105,6 +105,31 @@ async function onLogout() {
|
||||
</RouterLink>
|
||||
|
||||
<nav class="sidenav">
|
||||
<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>
|
||||
<RouterLink
|
||||
to="/tasks"
|
||||
class="nav-item"
|
||||
@@ -176,31 +201,6 @@ 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>
|
||||
|
||||
<!-- 하단: 접기 토글 + 사용자 프로필 + 메뉴(위로 펼침) -->
|
||||
|
||||
@@ -46,7 +46,7 @@ async function onLogin() {
|
||||
},
|
||||
{ silent: true },
|
||||
)
|
||||
await router.push('/projects')
|
||||
await router.push('/schedule')
|
||||
} catch (err) {
|
||||
const res = (
|
||||
err as { response?: { status?: number; data?: { error?: { message?: string } } } }
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
// 일정 — 전사 공유 주간 타임라인(간트형) 캘린더
|
||||
// 일정 — 전사 공유 캘린더(주간 간트 / 월간 달력 그리드 전환)
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import ScheduleTimeline from '@/components/schedule/ScheduleTimeline.vue'
|
||||
import ScheduleMonthGrid from '@/components/schedule/ScheduleMonthGrid.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 SegmentedTabs from '@/components/common/SegmentedTabs.vue'
|
||||
import { useScheduleStore, type ScheduleViewMode } from '@/stores/schedule.store'
|
||||
import { useDialogStore } from '@/stores/dialog.store'
|
||||
import type {
|
||||
ApiScheduleEvent,
|
||||
@@ -18,6 +20,12 @@ import type {
|
||||
const store = useScheduleStore()
|
||||
const dialog = useDialogStore()
|
||||
|
||||
// 보기 모드 토글 옵션
|
||||
const VIEW_OPTIONS: { value: ScheduleViewMode; label: string }[] = [
|
||||
{ value: 'week', label: '주간' },
|
||||
{ value: 'month', label: '월간' },
|
||||
]
|
||||
|
||||
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)
|
||||
@@ -107,8 +115,8 @@ async function onDeleteCategory(id: string): Promise<void> {
|
||||
<div class="navgroup">
|
||||
<button
|
||||
class="navbtn"
|
||||
title="이전 주"
|
||||
@click="store.goPrevWeek()"
|
||||
:title="store.viewMode === 'week' ? '이전 주' : '이전 달'"
|
||||
@click="store.goPrev()"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -121,8 +129,8 @@ async function onDeleteCategory(id: string): Promise<void> {
|
||||
</button>
|
||||
<button
|
||||
class="navbtn"
|
||||
title="다음 주"
|
||||
@click="store.goNextWeek()"
|
||||
:title="store.viewMode === 'week' ? '다음 주' : '다음 달'"
|
||||
@click="store.goNext()"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -136,7 +144,33 @@ async function onDeleteCategory(id: string): Promise<void> {
|
||||
</div>
|
||||
<span class="range">{{ store.rangeLabel }}</span>
|
||||
</div>
|
||||
<SegmentedTabs
|
||||
:model-value="store.viewMode"
|
||||
:options="VIEW_OPTIONS"
|
||||
@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"
|
||||
@@ -166,14 +200,24 @@ async function onDeleteCategory(id: string): Promise<void> {
|
||||
<div class="content-row">
|
||||
<div class="viewport">
|
||||
<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"
|
||||
@select="selId = $event"
|
||||
@add-category="openNewCategory"
|
||||
/>
|
||||
<ScheduleMonthGrid
|
||||
v-else
|
||||
:weeks="store.monthWeeks"
|
||||
:events="store.displayEvents"
|
||||
:categories="store.categories"
|
||||
:active-type-ids="store.activeTypeIds"
|
||||
:sel-id="selId"
|
||||
@select="selId = $event"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="selEvent"
|
||||
@@ -274,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;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/projects',
|
||||
redirect: '/schedule',
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
@@ -134,13 +134,13 @@ router.beforeEach(async (to) => {
|
||||
return { name: 'login' }
|
||||
}
|
||||
|
||||
// 이미 로그인 상태에서 로그인/회원가입 진입 → 프로젝트 목록으로
|
||||
// 이미 로그인 상태에서 로그인/회원가입 진입 → 기본 화면(일정)으로
|
||||
if (
|
||||
!to.meta.requiresAuth &&
|
||||
authStore.isLoggedIn &&
|
||||
(to.name === 'login' || to.name === 'signup')
|
||||
) {
|
||||
return { path: '/projects' }
|
||||
return { path: '/schedule' }
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
@@ -92,3 +92,63 @@ export function eventDateText(start: string, end: string): string {
|
||||
export function spanDays(start: string, end: string): number {
|
||||
return Math.round((dateOf(end).getTime() - dateOf(start).getTime()) / 86400000) + 1
|
||||
}
|
||||
|
||||
// ===== 월간(달력 그리드) =====
|
||||
|
||||
// 해당 월의 1일 ISO
|
||||
export function firstOfMonth(iso: string): string {
|
||||
const d = dateOf(iso)
|
||||
return isoOf(new Date(d.getFullYear(), d.getMonth(), 1))
|
||||
}
|
||||
|
||||
// iso + n개월 (말일 넘침 보정)
|
||||
export function addMonths(iso: string, n: number): string {
|
||||
const d = dateOf(iso)
|
||||
const day = d.getDate()
|
||||
const base = new Date(d.getFullYear(), d.getMonth() + n, 1)
|
||||
const dim = new Date(base.getFullYear(), base.getMonth() + 1, 0).getDate()
|
||||
base.setDate(Math.min(day, dim))
|
||||
return isoOf(base)
|
||||
}
|
||||
|
||||
// 월 라벨 — '2026년 6월'
|
||||
export function monthLabel(iso: string): string {
|
||||
const d = dateOf(iso)
|
||||
return `${d.getFullYear()}년 ${d.getMonth() + 1}월`
|
||||
}
|
||||
|
||||
// 달력 그리드의 하루(요일 + 이번 달 여부)
|
||||
export interface MonthDay extends WeekDay {
|
||||
inMonth: boolean
|
||||
}
|
||||
|
||||
// 월 달력 그리드 — 1일이 속한 주의 월요일부터 말일이 속한 주의 일요일까지(4~6주)
|
||||
export function monthGridWeeks(iso: string): MonthDay[][] {
|
||||
const monthIdx = dateOf(firstOfMonth(iso)).getMonth()
|
||||
const gridStart = mondayOf(firstOfMonth(iso))
|
||||
const first = dateOf(firstOfMonth(iso))
|
||||
const lastDayIso = isoOf(new Date(first.getFullYear(), first.getMonth() + 1, 0))
|
||||
const gridEnd = addDays(mondayOf(lastDayIso), 6)
|
||||
const today = todayIso()
|
||||
|
||||
const weeks: MonthDay[][] = []
|
||||
let cur = gridStart
|
||||
while (cur <= gridEnd) {
|
||||
const week: MonthDay[] = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const dayIso = addDays(cur, i)
|
||||
const dt = dateOf(dayIso)
|
||||
week.push({
|
||||
iso: dayIso,
|
||||
dow: DOW_KO[i] ?? '',
|
||||
d: dt.getDate(),
|
||||
weekend: i >= 5,
|
||||
isToday: dayIso === today,
|
||||
inMonth: dt.getMonth() === monthIdx,
|
||||
})
|
||||
}
|
||||
weeks.push(week)
|
||||
cur = addDays(cur, 7)
|
||||
}
|
||||
return weeks
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useSchedule } from '@/composables/useSchedule'
|
||||
import { useAuthStore } from './auth.store'
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
firstOfMonth,
|
||||
mondayOf,
|
||||
monthGridWeeks,
|
||||
monthLabel,
|
||||
todayIso,
|
||||
weekDays,
|
||||
weekRangeLabel,
|
||||
type MonthDay,
|
||||
type WeekDay,
|
||||
} from '@/shared/utils/scheduleDate'
|
||||
import type {
|
||||
@@ -17,41 +23,80 @@ import type {
|
||||
ScheduleEventPayload,
|
||||
} from '@/types/schedule'
|
||||
|
||||
// 일정 스토어 — 카테고리/일정/참석자 + 주간 네비 상태 + CRUD
|
||||
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())
|
||||
// 주 시작(월요일) ISO
|
||||
const weekStart = ref<string>(mondayOf(todayIso()))
|
||||
// '내 일정만' 보기 — 참석자/전직원/작성자 기준
|
||||
const mineOnly = ref(false)
|
||||
// 보기 모드(주간/월간) + 기준 날짜(이 날짜가 속한 주/월을 표시)
|
||||
const viewMode = ref<ScheduleViewMode>('week')
|
||||
const anchor = ref<string>(todayIso())
|
||||
const loading = ref(false)
|
||||
|
||||
// 파생: 주간 7일
|
||||
// --- 파생: 표시 단위 ---
|
||||
// 주간: 월요일 시작 7일
|
||||
const weekStart = computed(() => mondayOf(anchor.value))
|
||||
const days = computed<WeekDay[]>(() => weekDays(weekStart.value))
|
||||
const rangeLabel = computed(() => weekRangeLabel(weekStart.value))
|
||||
const weekEnd = computed(() => addDays(weekStart.value, 6))
|
||||
// 월간: 달력 그리드(주 배열)
|
||||
const monthWeeks = computed<MonthDay[][]>(() => monthGridWeeks(anchor.value))
|
||||
|
||||
// 카테고리별 현재 주 일정 수(필터 드롭다운 표기용)
|
||||
// 조회 범위(현재 모드가 화면에 보여주는 전체 기간)
|
||||
const rangeStart = computed(() => {
|
||||
if (viewMode.value === 'week') return weekStart.value
|
||||
const w = monthWeeks.value
|
||||
return w[0]?.[0]?.iso ?? weekStart.value
|
||||
})
|
||||
const rangeEnd = computed(() => {
|
||||
if (viewMode.value === 'week') return addDays(weekStart.value, 6)
|
||||
const w = monthWeeks.value
|
||||
return w[w.length - 1]?.[6]?.iso ?? addDays(weekStart.value, 6)
|
||||
})
|
||||
|
||||
// 상단 범위 라벨 — 주간: '2026년 6월 23 – 29일' / 월간: '2026년 6월'
|
||||
const rangeLabel = computed(() =>
|
||||
viewMode.value === 'week'
|
||||
? weekRangeLabel(weekStart.value)
|
||||
: 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
|
||||
})
|
||||
|
||||
// --- 로딩 ---
|
||||
|
||||
// 최초 진입 — 카테고리/참석자/주간 일정 동시 로드 + 필터 전체 활성화
|
||||
// 최초 진입 — 카테고리/참석자/현재 범위 일정 동시 로드 + 필터 전체 활성화
|
||||
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),
|
||||
api.listEvents(rangeStart.value, rangeEnd.value),
|
||||
])
|
||||
categories.value = cats
|
||||
people.value = ppl
|
||||
@@ -62,22 +107,33 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 주 일정만 재조회
|
||||
// 현재 범위 일정만 재조회
|
||||
async function reloadEvents(): Promise<void> {
|
||||
events.value = await api.listEvents(weekStart.value, weekEnd.value)
|
||||
events.value = await api.listEvents(rangeStart.value, rangeEnd.value)
|
||||
}
|
||||
|
||||
// --- 주간 네비 ---
|
||||
function goPrevWeek(): void {
|
||||
weekStart.value = addDays(weekStart.value, -7)
|
||||
// --- 네비(모드별) ---
|
||||
function setViewMode(mode: ScheduleViewMode): void {
|
||||
if (viewMode.value === mode) return
|
||||
viewMode.value = mode
|
||||
void reloadEvents()
|
||||
}
|
||||
function goNextWeek(): void {
|
||||
weekStart.value = addDays(weekStart.value, 7)
|
||||
function goPrev(): void {
|
||||
anchor.value =
|
||||
viewMode.value === 'week'
|
||||
? addDays(anchor.value, -7)
|
||||
: addMonths(firstOfMonth(anchor.value), -1)
|
||||
void reloadEvents()
|
||||
}
|
||||
function goNext(): void {
|
||||
anchor.value =
|
||||
viewMode.value === 'week'
|
||||
? addDays(anchor.value, 7)
|
||||
: addMonths(firstOfMonth(anchor.value), 1)
|
||||
void reloadEvents()
|
||||
}
|
||||
function goToday(): void {
|
||||
weekStart.value = mondayOf(todayIso())
|
||||
anchor.value = todayIso()
|
||||
void reloadEvents()
|
||||
}
|
||||
|
||||
@@ -93,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> {
|
||||
@@ -135,7 +194,7 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
const n = new Set(activeTypeIds.value)
|
||||
n.delete(id)
|
||||
activeTypeIds.value = n
|
||||
// 포함 일정이 cascade 삭제되었으므로 주간 일정 재조회
|
||||
// 포함 일정이 cascade 삭제되었으므로 재조회
|
||||
await reloadEvents()
|
||||
}
|
||||
|
||||
@@ -148,20 +207,26 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
return {
|
||||
categories,
|
||||
events,
|
||||
displayEvents,
|
||||
people,
|
||||
activeTypeIds,
|
||||
mineOnly,
|
||||
viewMode,
|
||||
weekStart,
|
||||
loading,
|
||||
days,
|
||||
monthWeeks,
|
||||
rangeLabel,
|
||||
counts,
|
||||
init,
|
||||
reloadEvents,
|
||||
goPrevWeek,
|
||||
goNextWeek,
|
||||
setViewMode,
|
||||
goPrev,
|
||||
goNext,
|
||||
goToday,
|
||||
toggleType,
|
||||
setAllTypes,
|
||||
toggleMineOnly,
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
|
||||
Reference in New Issue
Block a user