style: 일정/업무 UI 다듬기 — placeholder·참석자 콤보·AvatarStack 공용화
- 새 일정 모달 placeholder 를 프로젝트 기존 톤("~을 입력하세요/선택하세요")으로 통일
- 참석자 등록을 업무 생성의 담당자 등록과 동일한 콤보(검색 입력+후보 드롭다운+선택 칩)로 개편
('전 직원'은 드롭다운 항목/단일 칩으로 처리)
- 아바타 스택+오버플로(+N)+팝오버 목록을 공용 AvatarStack 으로 추출,
일정 상세 참석자 + 업무 상세 담당자 카드에 적용(담당자 팝오버에 역할 표시)
- 사용처가 사라진 ScheduleAvatar 삭제(UserAvatar/AvatarStack 으로 일원화)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
// 아바타 스택 + 오버플로(+N) + 클릭 팝오버 목록 공용 컴포넌트.
|
||||
// 좁은 영역에서 다수의 사람(참석자/담당자 등)을 컴팩트하게 표시하고,
|
||||
// 클릭하면 전체 목록(아바타 + 이름 + 선택적 보조문구 sub)을 펼친다.
|
||||
import { computed, ref } from 'vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useClickOutside } from '@/composables/useClickOutside'
|
||||
|
||||
interface StackPerson {
|
||||
id: string
|
||||
name: string
|
||||
avatarUrl?: string | null
|
||||
// 팝오버 목록의 이름 아래 보조문구(예: 역할). 없으면 표시 안 함
|
||||
sub?: string | null
|
||||
}
|
||||
const props = withDefaults(
|
||||
defineProps<{ people: StackPerson[]; max?: number }>(),
|
||||
{ max: 6 },
|
||||
)
|
||||
|
||||
const open = ref(false)
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
useClickOutside(open, () => (open.value = false), [rootRef], { esc: false })
|
||||
|
||||
// max 초과 시 (max-1)개만 보이고 나머지는 +N
|
||||
const shown = computed(() =>
|
||||
props.people.length > props.max
|
||||
? props.people.slice(0, props.max - 1)
|
||||
: props.people,
|
||||
)
|
||||
const extra = computed(() =>
|
||||
props.people.length > props.max ? props.people.length - (props.max - 1) : 0,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="as"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="as-trigger"
|
||||
:class="{ open }"
|
||||
@click="open = !open"
|
||||
>
|
||||
<span class="as-stack">
|
||||
<span
|
||||
v-for="p in shown"
|
||||
:key="p.id"
|
||||
class="as-av"
|
||||
><UserAvatar
|
||||
:url="p.avatarUrl"
|
||||
:name="p.name"
|
||||
/></span>
|
||||
<span
|
||||
v-if="extra > 0"
|
||||
class="as-more"
|
||||
>+{{ extra }}</span>
|
||||
</span>
|
||||
<span class="as-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="as-panel"
|
||||
>
|
||||
<div
|
||||
v-for="p in people"
|
||||
:key="p.id"
|
||||
class="as-prow"
|
||||
>
|
||||
<span class="as-av sm"><UserAvatar
|
||||
:url="p.avatarUrl"
|
||||
:name="p.name"
|
||||
/></span>
|
||||
<span class="as-pinfo">
|
||||
<span class="as-pnm">{{ p.name }}</span>
|
||||
<span
|
||||
v-if="p.sub"
|
||||
class="as-psub"
|
||||
>{{ p.sub }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.as {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.as-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0.3125rem 0.5rem 0.3125rem 0.3125rem;
|
||||
margin: -0.3125rem -0.5rem -0.3125rem -0.3125rem;
|
||||
border-radius: 0.5625rem;
|
||||
}
|
||||
.as-trigger:hover,
|
||||
.as-trigger.open {
|
||||
background: #f1f2f4;
|
||||
}
|
||||
.as-stack {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.as-av {
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.as-stack .as-av,
|
||||
.as-stack .as-more {
|
||||
margin-left: -0.5625rem;
|
||||
box-shadow: 0 0 0 2px var(--panel);
|
||||
}
|
||||
.as-stack .as-av:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
.as-more {
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
background: #eceef1;
|
||||
color: var(--text-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.as-chev {
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.as-chev svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
.as-trigger.open .as-chev {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.as-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.375rem);
|
||||
left: 0;
|
||||
min-width: 12.5rem;
|
||||
max-width: 16.25rem;
|
||||
max-height: 15rem;
|
||||
overflow-y: auto;
|
||||
z-index: 20;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: var(--shadow-pop);
|
||||
padding: 0.3125rem;
|
||||
}
|
||||
.as-prow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.as-prow:hover {
|
||||
background: #f4f5f7;
|
||||
}
|
||||
.as-av.sm {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
.as-pinfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.as-pnm {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.as-psub {
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
</style>
|
||||
@@ -1,38 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 일정 참석자 아바타 — 사이트 공용 UserAvatar 를 size(px)로 감싸는 얇은 어댑터.
|
||||
// 렌더링(이미지/실루엣)은 UserAvatar 가 단일 책임지고, 여기선 크기/원형 래퍼만 담당.
|
||||
// (.sa 클래스는 상세 패널 아바타 스택 CSS 의 훅으로 유지)
|
||||
// size 는 px 로 받되 rem(÷16)으로 렌더 — 앱 전체 rem 스케일과 정합.
|
||||
import { computed } from 'vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name?: string
|
||||
avatarUrl?: string | null
|
||||
size?: number
|
||||
}>(),
|
||||
{ name: '', avatarUrl: null, size: 21 },
|
||||
)
|
||||
const dim = computed(() => `${props.size / 16}rem`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="sa"
|
||||
:style="{ width: dim, height: dim }"
|
||||
>
|
||||
<UserAvatar
|
||||
:url="avatarUrl"
|
||||
:name="name"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sa {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 우측 슬라이드 상세 패널 — 선택 일정의 시간/장소/참석자/메모 + 수정·삭제
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useClickOutside } from '@/composables/useClickOutside'
|
||||
import ScheduleAvatar from './ScheduleAvatar.vue'
|
||||
import AvatarStack from '@/components/common/AvatarStack.vue'
|
||||
import { eventDateText, spanDays } from '@/shared/utils/scheduleDate'
|
||||
import type { ApiScheduleCategory, ApiScheduleEvent } from '@/types/schedule'
|
||||
|
||||
@@ -34,21 +33,7 @@ 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)
|
||||
useClickOutside(attOpen, () => (attOpen.value = false), [attRef], { esc: false })
|
||||
|
||||
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>
|
||||
@@ -122,57 +107,10 @@ const extra = computed(() =>
|
||||
<span class="dt-k">참석자</span>
|
||||
<span class="dt-v">
|
||||
<template v-if="ev.allHands">전 직원 대상</template>
|
||||
<div
|
||||
<AvatarStack
|
||||
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>
|
||||
:people="attendees"
|
||||
/>
|
||||
<template v-else>참석자 없음</template>
|
||||
</span>
|
||||
</div>
|
||||
@@ -347,90 +285,6 @@ const extra = computed(() =>
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.att-stack {
|
||||
position: relative;
|
||||
margin: -0.1875rem 0;
|
||||
}
|
||||
.att-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0.3125rem 0.5rem 0.3125rem 0.3125rem;
|
||||
margin: -0.3125rem -0.5rem -0.3125rem -0.3125rem;
|
||||
border-radius: 0.5625rem;
|
||||
}
|
||||
.att-trigger:hover,
|
||||
.att-trigger.open {
|
||||
background: #f1f2f4;
|
||||
}
|
||||
.avstack {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.avstack :deep(.sa),
|
||||
.avstack .av-more {
|
||||
margin-left: -0.5625rem;
|
||||
box-shadow: 0 0 0 2px var(--panel);
|
||||
}
|
||||
.avstack :deep(.sa):first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
.av-more {
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.75rem;
|
||||
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: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
.att-trigger.open .att-chev {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.att-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.375rem);
|
||||
left: 0;
|
||||
min-width: 12.5rem;
|
||||
max-width: 16.25rem;
|
||||
max-height: 15rem;
|
||||
overflow-y: auto;
|
||||
z-index: 20;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 0.3125rem;
|
||||
}
|
||||
.att-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.att-row:hover {
|
||||
background: #f4f5f7;
|
||||
}
|
||||
.dt-foot {
|
||||
flex-shrink: 0;
|
||||
padding: 0.875rem 1.375rem;
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
// 일정 생성/수정 모달
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import { useClickOutside } from '@/composables/useClickOutside'
|
||||
import ScheduleAvatar from './ScheduleAvatar.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import type {
|
||||
ApiScheduleCategory,
|
||||
ApiScheduleEvent,
|
||||
@@ -71,29 +70,46 @@ const valid = computed(
|
||||
!!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 '참석자 선택'
|
||||
// 참석자 — 검색 입력 + 후보 드롭다운 + 선택 칩 (업무 생성의 담당자 등록과 동일 방식)
|
||||
const attendeeQuery = ref('')
|
||||
const showAttendeeDropdown = ref(false)
|
||||
// 선택된 참석자(칩 표시용)
|
||||
const selectedAttendees = computed(() =>
|
||||
attendeeIds.value
|
||||
.map((id) => props.people.find((p) => p.id === id))
|
||||
.filter((p): p is ApiSchedulePerson => !!p),
|
||||
)
|
||||
// 후보 = 미선택 사용자, 이름 검색 필터
|
||||
const attendeeCandidates = computed(() => {
|
||||
const selected = new Set(attendeeIds.value)
|
||||
const kw = attendeeQuery.value.trim()
|
||||
return props.people
|
||||
.filter((p) => attendeeIds.value.includes(p.id))
|
||||
.map((p) => p.name)
|
||||
.join(', ')
|
||||
.filter((p) => !selected.has(p.id))
|
||||
.filter((p) => !kw || p.name.includes(kw))
|
||||
})
|
||||
function toggleAll(): void {
|
||||
allHands.value = !allHands.value
|
||||
if (allHands.value) attendeeIds.value = []
|
||||
}
|
||||
function togglePerson(id: string): void {
|
||||
function addAttendee(id: string): void {
|
||||
allHands.value = false
|
||||
attendeeIds.value = attendeeIds.value.includes(id)
|
||||
? attendeeIds.value.filter((x) => x !== id)
|
||||
: [...attendeeIds.value, id]
|
||||
if (!attendeeIds.value.includes(id)) {
|
||||
attendeeIds.value = [...attendeeIds.value, id]
|
||||
}
|
||||
attendeeQuery.value = ''
|
||||
}
|
||||
function removeAttendee(id: string): void {
|
||||
attendeeIds.value = attendeeIds.value.filter((x) => x !== id)
|
||||
}
|
||||
function selectAllHands(): void {
|
||||
allHands.value = true
|
||||
attendeeIds.value = []
|
||||
attendeeQuery.value = ''
|
||||
showAttendeeDropdown.value = false
|
||||
}
|
||||
function clearAllHands(): void {
|
||||
allHands.value = false
|
||||
}
|
||||
function onAttendeeBlur(): void {
|
||||
// 항목은 @mousedown.prevent 로 선택하므로 약간 지연 후 닫는다
|
||||
setTimeout(() => (showAttendeeDropdown.value = false), 120)
|
||||
}
|
||||
// 참석자 멀티셀렉트 바깥클릭 닫기(ESC 는 모달 닫기에서 처리)
|
||||
useClickOutside(msOpen, () => (msOpen.value = false), [msRef], { esc: false })
|
||||
|
||||
function submit(): void {
|
||||
if (!valid.value) return
|
||||
@@ -121,7 +137,7 @@ function submit(): void {
|
||||
<input
|
||||
v-model="title"
|
||||
class="form-input"
|
||||
placeholder="일정 제목"
|
||||
placeholder="일정 제목을 입력하세요"
|
||||
autofocus
|
||||
>
|
||||
</label>
|
||||
@@ -188,23 +204,22 @@ function submit(): void {
|
||||
<input
|
||||
v-model="place"
|
||||
class="form-input"
|
||||
placeholder="장소 또는 화상"
|
||||
placeholder="장소를 입력하세요"
|
||||
>
|
||||
</label>
|
||||
<div class="form-field">
|
||||
<span class="form-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">
|
||||
<div class="combo">
|
||||
<div class="combo-field">
|
||||
<input
|
||||
v-model="attendeeQuery"
|
||||
class="combo-input"
|
||||
placeholder="참석자를 검색하세요"
|
||||
autocomplete="off"
|
||||
@focus="showAttendeeDropdown = true"
|
||||
@blur="onAttendeeBlur"
|
||||
>
|
||||
<span class="combo-caret">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -214,76 +229,82 @@ function submit(): void {
|
||||
stroke-linejoin="round"
|
||||
><path d="m6 9 6 6 6-6" /></svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 참석자 후보 드롭다운 -->
|
||||
<div
|
||||
v-if="msOpen"
|
||||
class="ms-panel"
|
||||
v-if="showAttendeeDropdown"
|
||||
class="assignee-dd"
|
||||
>
|
||||
<!-- 전 직원(전체 대상) -->
|
||||
<button
|
||||
v-if="!allHands && !attendeeQuery.trim()"
|
||||
type="button"
|
||||
class="ms-item"
|
||||
:class="{ on: allHands }"
|
||||
@click="toggleAll"
|
||||
class="assignee-dd-item"
|
||||
@mousedown.prevent="selectAllHands"
|
||||
>
|
||||
<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>
|
||||
<span class="ad-all">전</span>
|
||||
<span class="ad-nm">전 직원</span>
|
||||
</button>
|
||||
<div class="ms-div" />
|
||||
<button
|
||||
v-for="p in people"
|
||||
v-for="p in attendeeCandidates"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="ms-item"
|
||||
:class="{ on: attendeeIds.includes(p.id) }"
|
||||
@click="togglePerson(p.id)"
|
||||
class="assignee-dd-item"
|
||||
@mousedown.prevent="addAttendee(p.id)"
|
||||
>
|
||||
<ScheduleAvatar
|
||||
<span class="avatar"><UserAvatar
|
||||
:url="p.avatarUrl"
|
||||
: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>
|
||||
/></span>
|
||||
<span class="ad-nm">{{ p.name }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="people.length === 0"
|
||||
class="ms-empty"
|
||||
v-if="attendeeCandidates.length === 0 && (allHands || attendeeQuery.trim())"
|
||||
class="assignee-dd-msg"
|
||||
>
|
||||
등록된 사용자가 없습니다
|
||||
{{ people.length === 0 ? '등록된 사용자가 없습니다.' : '추가할 사용자가 없습니다.' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 선택된 참석자 칩 -->
|
||||
<div
|
||||
v-if="allHands || selectedAttendees.length"
|
||||
class="assignee-chips"
|
||||
>
|
||||
<span
|
||||
v-if="allHands"
|
||||
class="chip"
|
||||
>
|
||||
<span class="ad-all sm">전</span>전 직원
|
||||
<span
|
||||
class="x"
|
||||
@click="clearAllHands"
|
||||
>×</span>
|
||||
</span>
|
||||
<template v-else>
|
||||
<span
|
||||
v-for="p in selectedAttendees"
|
||||
:key="p.id"
|
||||
class="chip"
|
||||
>
|
||||
<span class="avatar"><UserAvatar
|
||||
:url="p.avatarUrl"
|
||||
:name="p.name"
|
||||
/></span>
|
||||
{{ p.name }}
|
||||
<span
|
||||
class="x"
|
||||
@click="removeAttendee(p.id)"
|
||||
>×</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<label class="form-field"><span class="form-label">설명</span>
|
||||
<textarea
|
||||
v-model="desc"
|
||||
class="form-input form-textarea"
|
||||
placeholder="메모"
|
||||
placeholder="일정에 대한 메모를 입력하세요 (선택)"
|
||||
rows="3"
|
||||
/>
|
||||
</label>
|
||||
@@ -322,123 +343,146 @@ function submit(): void {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 참석자 멀티셀렉트 */
|
||||
.ms {
|
||||
/* 참석자 — 업무 생성의 담당자 등록과 동일한 콤보(검색 입력 + 후보 드롭다운 + 선택 칩) */
|
||||
.combo {
|
||||
position: relative;
|
||||
}
|
||||
.ms-field {
|
||||
.combo-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
padding: 0 0.625rem 0 0.75rem;
|
||||
gap: 0.375rem;
|
||||
padding: 0.4375rem 0.5rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.ms-field.empty .ms-val {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.ms-field.open {
|
||||
.combo-field:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.ms-field .ms-val {
|
||||
.combo-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 4.375rem;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.25rem 0.125rem;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
.ms-field .chev {
|
||||
display: grid;
|
||||
.combo-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.ms-field .chev svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
.combo-caret {
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ms-panel {
|
||||
.combo-caret svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.assignee-dd {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.375rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
max-height: 13.75rem;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5625rem;
|
||||
box-shadow: var(--shadow-pop);
|
||||
padding: 0.375rem;
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ms-item {
|
||||
.assignee-dd-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
padding: 0.4375rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.ms-item:hover {
|
||||
.assignee-dd-item:hover {
|
||||
background: #f5f6f8;
|
||||
}
|
||||
.ms-item.on {
|
||||
color: var(--text);
|
||||
.assignee-dd-item .avatar {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ms-item .ms-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
.ad-nm {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ms-item .ms-check {
|
||||
.assignee-dd-msg {
|
||||
padding: 0.625rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
.assignee-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: #f1f2f4;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 0.1875rem 0.5rem 0.1875rem 0.1875rem;
|
||||
font-size: 0.781rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip .avatar {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--accent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.ms-item .ms-check svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
.chip .x {
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1;
|
||||
margin-left: 0.0625rem;
|
||||
}
|
||||
.ms-all {
|
||||
/* 참석자 아바타(ScheduleAvatar :size="20" = 1.25rem)와 동일 크기 */
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
.chip .x:hover {
|
||||
color: var(--red);
|
||||
}
|
||||
/* 전 직원 표식(드롭다운/칩 공용) */
|
||||
.ad-all {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.5625rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: #9298a3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ms-div {
|
||||
height: 0.0625rem;
|
||||
background: var(--border);
|
||||
margin: 0.25rem 0.125rem;
|
||||
}
|
||||
.ms-empty {
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
.ad-all.sm {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useTaskStore } from '@/stores/task.store'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useDialog } from '@/composables/useDialog'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import AvatarStack from '@/components/common/AvatarStack.vue'
|
||||
import StatusSelect from '@/components/StatusSelect.vue'
|
||||
import { attachmentKindOf, fileBadge } from '@/shared/utils/format'
|
||||
import {
|
||||
@@ -182,6 +183,15 @@ const assigneeNames = computed(() => {
|
||||
if (names.length === 1) return names[0]
|
||||
return `${names[0]} 외 ${names.length - 1}명`
|
||||
})
|
||||
// 담당자 아바타 스택용 — 팝오버에 역할(role)을 보조문구로 표시
|
||||
const assigneeStack = computed(() =>
|
||||
(task.value?.assignees ?? []).map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
avatarUrl: a.avatarUrl,
|
||||
sub: a.role,
|
||||
})),
|
||||
)
|
||||
// 수정 요청 모달 열기 — 모든 항목을 '완료'로 초기화(담당자가 완료를 주장하며 요청)
|
||||
function openChanges() {
|
||||
const checks: Record<string, boolean> = {}
|
||||
@@ -1204,18 +1214,15 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
<div class="side-label">
|
||||
담당자 · {{ task.assignees.length }}명
|
||||
</div>
|
||||
<div class="assignees">
|
||||
<div
|
||||
v-for="a in task.assignees"
|
||||
:key="a.id"
|
||||
class="ass"
|
||||
>
|
||||
<span class="avatar"><UserAvatar
|
||||
:url="a.avatarUrl"
|
||||
:name="a.name"
|
||||
/></span>
|
||||
<span><div class="nm">{{ a.name }}</div><div class="rl">{{ a.role }}</div></span>
|
||||
</div>
|
||||
<AvatarStack
|
||||
v-if="task.assignees.length"
|
||||
:people="assigneeStack"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="ass-empty"
|
||||
>
|
||||
지정된 담당자가 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-field">
|
||||
@@ -3154,30 +3161,9 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
.status-badge.done .dot {
|
||||
background: var(--green);
|
||||
}
|
||||
.assignees {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5625rem;
|
||||
}
|
||||
.ass {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
}
|
||||
.ass .avatar {
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.ass .nm {
|
||||
.ass-empty {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ass .rl {
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user