Merge: 프론트 공용화 리팩터 (Phase 0-4)

refactor/frontend-commonization 머지 — useClickOutside/useDropdownMenu 훅,
BaseModal/SegmentedTabs/LoadMore/EmptyState 공용 컴포넌트, .form-*/.empty-state
전역 클래스, 작업상태 SSOT(workStatus) + 업무상태 'todo' 라벨 '대기' 통일.
This commit is contained in:
2026-06-23 00:10:16 +09:00
22 changed files with 947 additions and 1242 deletions
+98
View File
@@ -569,6 +569,90 @@ body {
color: var(--text-3);
}
/* ============================================================
* 폼 필드 (폼 모달 공통 — BaseModal 본문 등에서 사용)
* ============================================================ */
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-size: 0.781rem;
font-weight: 600;
color: var(--text-2);
}
.form-row {
display: flex;
gap: 0.75rem;
}
.form-row .form-field {
flex: 1;
min-width: 0;
}
.form-input {
width: 100%;
height: 2.5rem;
padding: 0 0.75rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
background: #fff;
}
.form-input::placeholder {
color: var(--text-3);
}
.form-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.form-textarea {
height: auto;
min-height: 5.75rem;
padding: 0.625rem 0.75rem;
line-height: 1.5;
resize: vertical;
}
/* 네이티브 select 래퍼 + 셰브론 */
.form-select-wrap {
position: relative;
}
.form-select {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 2.5rem;
padding: 0 2.125rem 0 0.75rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
background: #fff;
cursor: pointer;
}
.form-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.form-select-chev {
position: absolute;
right: 0.625rem;
top: 50%;
transform: translateY(-50%);
display: grid;
color: var(--text-3);
pointer-events: none;
}
.form-select-chev svg {
width: 0.9375rem;
height: 0.9375rem;
}
/* ============================================================
* 더보기 버튼 (목록 페이지네이션 공통)
* ============================================================ */
@@ -593,3 +677,17 @@ body {
opacity: 0.6;
cursor: default;
}
/* ============================================================
* 빈/상태 메시지 박스 (목록 로딩·빈 상태 공통)
* ============================================================ */
.empty-state {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-sm);
padding: 2.5rem 1.25rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-3);
}
+8 -57
View File
@@ -1,7 +1,8 @@
<script setup lang="ts" generic="T extends string">
// 커스텀 드롭다운(셀렉트) — 네이티브 select 대신 디자인 토큰에 맞춘 정렬/필터 공통 UI.
// 메뉴는 body 로 Teleport + fixed 위치로 띄워, 스크롤/overflow 컨테이너 안에서도 잘리지 않는다.
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed } from 'vue'
import { useDropdownMenu } from '@/composables/useDropdownMenu'
interface Option {
value: T
@@ -23,75 +24,25 @@ const props = withDefaults(
)
const emit = defineEmits<{ (e: 'update:modelValue', value: T): void }>()
const open = ref(false)
const root = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(null)
// 메뉴 fixed 위치(트리거 기준 계산)
const menuStyle = ref<Record<string, string>>({})
// 메뉴 위치/토글/바깥클릭 닫기 — 공용 훅
const { open, triggerRef, menuRef, menuStyle, toggle, close } = useDropdownMenu({
align: props.align,
matchWidth: true,
})
// 현재 선택된 옵션 라벨(트리거에 표시)
const selectedLabel = computed(
() => props.options.find((o) => o.value === props.modelValue)?.label ?? '',
)
// 트리거 위치 기준으로 메뉴 좌표 계산(뷰포트 fixed)
function positionMenu(): void {
const el = triggerRef.value
if (!el) return
const r = el.getBoundingClientRect()
const style: Record<string, string> = {
top: `${r.bottom + 6}px`,
minWidth: `${r.width}px`,
}
if (props.align === 'right') {
style.right = `${Math.max(0, window.innerWidth - r.right)}px`
} else {
style.left = `${r.left}px`
}
menuStyle.value = style
}
function toggle(): void {
open.value = !open.value
if (open.value) void nextTick(positionMenu)
}
function select(value: T): void {
emit('update:modelValue', value)
open.value = false
close()
}
// 바깥 클릭(트리거/메뉴 외부) / Esc 로 닫기
function onDocClick(e: MouseEvent): void {
if (!open.value) return
const t = e.target as Node
if (root.value?.contains(t) || menuRef.value?.contains(t)) return
open.value = false
}
function onKeydown(e: KeyboardEvent): void {
if (open.value && e.key === 'Escape') open.value = false
}
// 스크롤/리사이즈 시 위치가 어긋나므로 닫는다(간단·안전)
function onReposition(): void {
if (open.value) open.value = false
}
onMounted(() => {
document.addEventListener('click', onDocClick)
document.addEventListener('keydown', onKeydown)
window.addEventListener('scroll', onReposition, true)
window.addEventListener('resize', onReposition)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick)
document.removeEventListener('keydown', onKeydown)
window.removeEventListener('scroll', onReposition, true)
window.removeEventListener('resize', onReposition)
})
</script>
<template>
<div
ref="root"
class="dd"
:class="{ push: pushRight, block }"
>
+12 -44
View File
@@ -1,60 +1,28 @@
<script setup lang="ts">
// 진행 상태(대기/진행 중/완료) 선택 드롭다운 — 상태색 테마. 메뉴는 body Teleport(스크롤 클리핑 방지).
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed } from 'vue'
import { useDropdownMenu } from '@/composables/useDropdownMenu'
import {
WORK_STATUS_META as META,
WORK_STATUS_ORDER as ORDER,
} from '@/shared/constants/workStatus'
import type { ChecklistWorkStatus } from '@/mock/relay.mock'
const props = defineProps<{ modelValue: ChecklistWorkStatus }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: ChecklistWorkStatus): void }>()
const META: Record<ChecklistWorkStatus, { label: string; color: string; weak: string }> = {
todo: { label: '대기', color: '#9298a3', weak: '#eef0f2' },
doing: { label: '진행 중', color: '#2563eb', weak: '#e9f0fd' },
done: { label: '완료', color: '#1f9d57', weak: '#e7f5ed' },
}
const ORDER: ChecklistWorkStatus[] = ['todo', 'doing', 'done']
const cur = computed(() => META[props.modelValue])
const open = ref(false)
const triggerRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(null)
const menuStyle = ref<Record<string, string>>({})
// 메뉴 위치/토글/바깥클릭 닫기 — 공용 훅(우측 정렬, 너비는 CSS 고정)
const { open, triggerRef, menuRef, menuStyle, toggle, close } = useDropdownMenu({
align: 'right',
gap: 5,
})
function positionMenu(): void {
const el = triggerRef.value
if (!el) return
const r = el.getBoundingClientRect()
menuStyle.value = {
top: `${r.bottom + 5}px`,
right: `${Math.max(0, window.innerWidth - r.right)}px`,
}
}
function toggle(): void {
open.value = !open.value
if (open.value) void nextTick(positionMenu)
}
function pick(v: ChecklistWorkStatus): void {
emit('update:modelValue', v)
open.value = false
close()
}
function onDoc(e: MouseEvent): void {
if (!open.value) return
const t = e.target as Node
if (triggerRef.value?.contains(t) || menuRef.value?.contains(t)) return
open.value = false
}
function onReposition(): void {
if (open.value) open.value = false
}
onMounted(() => {
document.addEventListener('click', onDoc)
window.addEventListener('scroll', onReposition, true)
window.addEventListener('resize', onReposition)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDoc)
window.removeEventListener('scroll', onReposition, true)
window.removeEventListener('resize', onReposition)
})
</script>
<template>
@@ -0,0 +1,190 @@
<script setup lang="ts">
// 공용 폼 모달 셸 — backdrop + 헤더(제목/닫기) + 본문 슬롯 + 푸터 슬롯.
// Teleport(body) · ESC · 바깥(backdrop) 클릭 닫기 내장.
// 전역 .modal*(확인 다이얼로그 전용)과 충돌하지 않도록 bm- 접두 클래스를 쓴다.
// 폼 필드는 전역 .form-* 클래스(relay.css)를 본문 슬롯에서 사용한다.
import { onBeforeUnmount, onMounted } from 'vue'
withDefaults(
defineProps<{
title: string
// 너비: md(기본 33.75rem) | sm(27.5rem)
size?: 'md' | 'sm'
}>(),
{ size: 'md' },
)
const emit = defineEmits<{ (e: 'close'): void }>()
function onKey(e: KeyboardEvent): void {
if (e.key === 'Escape') emit('close')
}
onMounted(() => document.addEventListener('keydown', onKey))
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
</script>
<template>
<Teleport to="body">
<div
class="bm-back"
@mousedown.self="emit('close')"
>
<div
class="bm"
:class="{ 'bm-sm': size === 'sm' }"
role="dialog"
aria-modal="true"
>
<div class="bm-head">
<div class="bm-head-text">
<h3>{{ title }}</h3>
<div
v-if="$slots.subtitle"
class="bm-sub"
>
<slot name="subtitle" />
</div>
</div>
<button
type="button"
class="bm-x"
aria-label="닫기"
@click="emit('close')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
<div class="bm-body">
<slot />
</div>
<div
v-if="$slots.foot"
class="bm-foot"
>
<slot name="foot" />
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.bm-back {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(20, 24, 33, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.bm {
width: 100%;
max-width: 33.75rem;
max-height: 90vh;
overflow-y: auto;
background: #fff;
border-radius: 0.875rem;
box-shadow: 0 24px 64px rgba(20, 24, 33, 0.32);
display: flex;
flex-direction: column;
}
.bm.bm-sm {
max-width: 27.5rem;
}
.bm-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 1.375rem 1.5rem 1rem;
position: sticky;
top: 0;
background: #fff;
border-radius: 0.875rem 0.875rem 0 0;
z-index: 1;
}
.bm-head h3 {
font-size: 1.0625rem;
font-weight: 700;
letter-spacing: -0.01875rem;
}
.bm-sub {
font-size: 0.8125rem;
color: var(--text-2);
margin-top: 0.25rem;
}
.bm-sub :deep(b) {
color: var(--text-2);
font-weight: 600;
}
.bm-x {
flex-shrink: 0;
}
.bm-x {
width: 2rem;
height: 2rem;
border: none;
background: transparent;
color: var(--text-3);
border-radius: var(--radius-sm);
cursor: pointer;
display: grid;
place-items: center;
margin-right: -0.3125rem;
}
.bm-x:hover {
background: #f1f2f4;
color: var(--text);
}
.bm-x svg {
width: 1.125rem;
height: 1.125rem;
}
.bm-body {
padding: 0.5rem 1.5rem 0.75rem;
display: flex;
flex-direction: column;
gap: 1.125rem;
text-align: left;
}
.bm-foot {
display: flex;
justify-content: flex-end;
gap: 0.5625rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
background: #fafbfc;
position: sticky;
bottom: 0;
border-radius: 0 0 0.875rem 0.875rem;
}
/* 푸터 버튼 — 전역 .mbtn(확인 다이얼로그용 flex:1) 대신 폼 모달용 우측정렬 컴팩트 */
.bm-foot :deep(.mbtn) {
flex: 0 0 auto;
height: 2.375rem;
padding: 0 1rem;
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.bm-foot :deep(.mbtn svg) {
width: 0.9375rem;
height: 0.9375rem;
}
.bm-foot :deep(.mbtn:disabled) {
opacity: 0.45;
cursor: not-allowed;
}
.bm-foot :deep(.mbtn.primary:disabled),
.bm-foot :deep(.mbtn.primary:disabled:hover) {
background: var(--accent);
border-color: var(--accent);
}
</style>
@@ -0,0 +1,10 @@
<script setup lang="ts">
// 빈/상태 메시지 박스 공용 컴포넌트 — 목록 로딩·빈 상태 등에 사용.
// 전역 .empty-state 카드 스타일을 쓰고, 메시지는 기본 슬롯으로 전달한다.
</script>
<template>
<div class="empty-state">
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
// 목록 페이지네이션 '더보기' 버튼 공용 컴포넌트 — 전역 .load-more 스타일 사용.
// 부모는 보통 v-if="hasMore" 로 노출하고, @load 에서 다음 페이지를 불러온다.
defineProps<{ loading?: boolean }>()
const emit = defineEmits<{ (e: 'load'): void }>()
</script>
<template>
<button
class="load-more"
type="button"
:disabled="loading"
@click="emit('load')"
>
{{ loading ? '불러오는 중…' : '더보기' }}
</button>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts" generic="T extends string">
// 세그먼트 토글(필터/탭) 공용 컴포넌트 — 전역 .segmented 스타일을 그대로 사용.
// options 의 count 가 있으면 라벨 뒤에 수량(.n)을 표기한다.
interface SegOption {
value: T
label: string
count?: number
}
defineProps<{ modelValue: T; options: SegOption[] }>()
const emit = defineEmits<{ (e: 'update:modelValue', value: T): void }>()
</script>
<template>
<div class="segmented">
<button
v-for="o in options"
:key="o.value"
type="button"
:class="{ active: o.value === modelValue }"
@click="emit('update:modelValue', o.value)"
>
{{ o.label }}<span
v-if="o.count !== undefined"
class="n"
>{{ o.count }}</span>
</button>
</div>
</template>
@@ -1,9 +1,10 @@
<script setup lang="ts">
// 일정 참석자 아바타 — 사이트 공용 UserAvatar 와 동일한 방식.
// 프로필 이미지가 있으면 사진, 없으면 사람 실루엣 placeholder(색상+이니셜 방식은 폐기).
import { computed } from 'vue'
// 일정 참석자 아바타 — 사이트 공용 UserAvatar 를 size(px)로 감싸는 얇은 어댑터.
// 렌더링(이미지/실루엣)은 UserAvatar 가 단일 책임지고, 여기선 크기/원형 래퍼만 담당.
// (.sa 클래스는 상세 패널 아바타 스택 CSS 의 훅으로 유지)
import UserAvatar from '@/components/UserAvatar.vue'
const props = withDefaults(
withDefaults(
defineProps<{
name?: string
avatarUrl?: string | null
@@ -11,52 +12,24 @@ const props = withDefaults(
}>(),
{ name: '', avatarUrl: null, size: 21 },
)
const dim = computed(() => `${props.size}px`)
</script>
<template>
<span
class="sa"
:style="{ width: dim, height: dim }"
:style="{ width: `${size}px`, height: `${size}px` }"
>
<img
v-if="avatarUrl"
class="sa-img"
:src="avatarUrl"
:alt="name || '프로필 이미지'"
>
<svg
v-else
class="sa-ph"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M12 12.5a4.25 4.25 0 1 0 0-8.5 4.25 4.25 0 0 0 0 8.5Z" />
<path d="M4.5 19.25c0-3.04 3.36-5 7.5-5s7.5 1.96 7.5 5v.75H4.5v-.75Z" />
</svg>
<UserAvatar
:url="avatarUrl"
:name="name"
/>
</span>
</template>
<style scoped>
.sa {
display: grid;
place-items: center;
border-radius: 50%;
overflow: hidden;
background: #e5e7eb;
flex-shrink: 0;
}
.sa-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.sa-ph {
width: 64%;
height: 64%;
color: #9aa1ac;
flex-shrink: 0;
border-radius: 50%;
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 카테고리 생성/수정 모달 — 이름 + 색상(hue 슬라이더)
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, ref } from 'vue'
import BaseModal from '@/components/common/BaseModal.vue'
import type {
ApiScheduleCategory,
ScheduleCategoryPayload,
@@ -57,181 +58,60 @@ function submit(): void {
if (!valid.value) return
emit('save', { label: name.value.trim(), color: color.value })
}
function onKey(ev: KeyboardEvent): void {
if (ev.key === 'Escape') emit('close')
}
onMounted(() => document.addEventListener('keydown', onKey))
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
</script>
<template>
<div
class="modal-back"
@mousedown.self="emit('close')"
<BaseModal
:title="isEdit ? '카테고리 편집' : '새 카테고리'"
size="sm"
@close="emit('close')"
>
<div class="modal modal-sm">
<div class="modal-head">
<h3>{{ isEdit ? '카테고리 편집' : '새 카테고리' }}</h3>
<button
class="modal-x"
@click="emit('close')"
<label class="form-field"><span class="form-label">이름</span>
<input
v-model="name"
class="form-input"
placeholder="카테고리 이름"
autofocus
@keydown.enter="submit"
>
</label>
<div class="form-field">
<span class="form-label">색상</span>
<div class="cat-color">
<span
class="cat-dot"
:style="{ background: color }"
/>
<input
v-model.number="hue"
type="range"
min="0"
max="359"
class="hue-slider"
:style="{ '--cur': color }"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
<div class="modal-body">
<label class="f-field"><span class="f-label">이름</span>
<input
v-model="name"
class="f-input"
placeholder="카테고리 이름"
autofocus
@keydown.enter="submit"
>
</label>
<div class="f-field">
<span class="f-label">색상</span>
<div class="cat-color">
<span
class="cat-dot"
:style="{ background: color }"
/>
<input
v-model.number="hue"
type="range"
min="0"
max="359"
class="hue-slider"
:style="{ '--cur': color }"
>
</div>
</div>
</div>
<div class="modal-foot">
<button
class="mbtn"
@click="emit('close')"
>
취소
</button>
<button
class="mbtn primary"
:disabled="!valid"
@click="submit"
>
{{ isEdit ? '저장' : '추가' }}
</button>
</div>
</div>
</div>
<template #foot>
<button
class="mbtn"
@click="emit('close')"
>
취소
</button>
<button
class="mbtn primary"
:disabled="!valid"
@click="submit"
>
{{ isEdit ? '저장' : '추가' }}
</button>
</template>
</BaseModal>
</template>
<style scoped>
.modal-back {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(20, 24, 33, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.modal {
width: 100%;
max-width: 33.75rem;
background: #fff;
border-radius: 0.875rem;
box-shadow: 0 24px 64px rgba(20, 24, 33, 0.32);
display: flex;
flex-direction: column;
}
.modal.modal-sm {
max-width: 27.5rem;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.375rem 1.5rem 1rem;
}
.modal-head h3 {
font-size: 1.0625rem;
font-weight: 700;
letter-spacing: -0.01875rem;
}
.modal-x {
width: 2rem;
height: 2rem;
border: none;
background: transparent;
color: var(--text-3);
border-radius: var(--radius-sm);
cursor: pointer;
display: grid;
place-items: center;
margin-right: -0.3125rem;
}
.modal-x:hover {
background: #f1f2f4;
color: var(--text);
}
.modal-x svg {
width: 1.125rem;
height: 1.125rem;
}
.modal-body {
padding: 0.5rem 1.5rem 0.75rem;
display: flex;
flex-direction: column;
gap: 1.125rem;
/* 전역 .modal-body(확인 다이얼로그용)의 가운데 정렬을 폼 모달에선 왼쪽으로 */
text-align: left;
}
.modal-foot {
display: flex;
justify-content: flex-end;
gap: 0.5625rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
background: #fafbfc;
border-radius: 0 0 0.875rem 0.875rem;
}
.f-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.f-label {
font-size: 0.781rem;
font-weight: 600;
color: var(--text-2);
}
.f-input {
width: 100%;
height: 2.5rem;
padding: 0 0.75rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
background: #fff;
}
.f-input::placeholder {
color: var(--text-3);
}
.f-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.cat-color {
display: flex;
align-items: center;
@@ -284,20 +164,4 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
box-shadow: 0 1px 4px rgba(20, 24, 33, 0.4);
cursor: grab;
}
/* 푸터 버튼 — 전역 .mbtn(확인 다이얼로그용 flex:1) 대신 폼 모달용 우측정렬 컴팩트(멤버 초대 모달과 동일).
색/서체/테두리는 전역 .mbtn / .mbtn.primary 가 담당한다. */
.mbtn {
flex: 0 0 auto;
height: 2.375rem;
padding: 0 1rem;
}
.mbtn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.mbtn.primary:disabled,
.mbtn.primary:disabled:hover {
background: var(--accent);
border-color: var(--accent);
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 우측 슬라이드 상세 패널 — 선택 일정의 시간/장소/참석자/메모 + 수정·삭제
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { computed, ref, watch } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
import ScheduleAvatar from './ScheduleAvatar.vue'
import { eventDateText, spanDays } from '@/shared/utils/scheduleDate'
import type { ApiScheduleCategory, ApiScheduleEvent } from '@/types/schedule'
@@ -36,15 +37,7 @@ const dur = computed(() =>
// 참석자 스택 토글
const attOpen = ref(false)
const attRef = ref<HTMLElement | null>(null)
function onDoc(e: MouseEvent): void {
if (!attOpen.value) return
if (attRef.value && !attRef.value.contains(e.target as Node)) attOpen.value = false
}
watch(attOpen, (v) => {
if (v) document.addEventListener('mousedown', onDoc)
else document.removeEventListener('mousedown', onDoc)
})
onBeforeUnmount(() => document.removeEventListener('mousedown', onDoc))
useClickOutside(attOpen, () => (attOpen.value = false), [attRef], { esc: false })
const MAX = 6
const attendees = computed(() => ev.value?.attendees ?? [])
@@ -1,6 +1,8 @@
<script setup lang="ts">
// 일정 생성/수정 모달
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import BaseModal from '@/components/common/BaseModal.vue'
import { useClickOutside } from '@/composables/useClickOutside'
import ScheduleAvatar from './ScheduleAvatar.vue'
import type {
ApiScheduleCategory,
@@ -90,22 +92,8 @@ function togglePerson(id: string): void {
? attendeeIds.value.filter((x) => x !== id)
: [...attendeeIds.value, id]
}
function onDoc(ev: MouseEvent): void {
if (msRef.value && !msRef.value.contains(ev.target as Node)) msOpen.value = false
}
watch(msOpen, (v) => {
if (v) document.addEventListener('mousedown', onDoc)
else document.removeEventListener('mousedown', onDoc)
})
function onKey(ev: KeyboardEvent): void {
if (ev.key === 'Escape') emit('close')
}
onMounted(() => document.addEventListener('keydown', onKey))
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', onDoc)
})
// 참석자 멀티셀렉트 바깥클릭 닫기(ESC 는 모달 닫기에서 처리)
useClickOutside(msOpen, () => (msOpen.value = false), [msRef], { esc: false })
function submit(): void {
if (!valid.value) return
@@ -125,373 +113,201 @@ function submit(): void {
</script>
<template>
<div
class="modal-back"
@mousedown.self="emit('close')"
<BaseModal
:title="isEdit ? '일정 수정' : '새 일정'"
@close="emit('close')"
>
<div class="modal">
<div class="modal-head">
<h3>{{ isEdit ? '일정 수정' : '새 일정' }}</h3>
<button
class="modal-x"
@click="emit('close')"
<label class="form-field"><span class="form-label">제목</span>
<input
v-model="title"
class="form-input"
placeholder="일정 제목"
autofocus
>
</label>
<label class="form-field"><span class="form-label">유형</span>
<div class="form-select-wrap">
<select
v-model="categoryId"
class="form-select"
>
<option
v-for="c in ordered"
:key="c.id"
:value="c.id"
>
{{ c.label }}
</option>
</select>
<span class="form-select-chev">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-width="2"
stroke-linecap="round"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
stroke-linejoin="round"
><path d="m6 9 6 6 6-6" /></svg>
</span>
</div>
<div class="modal-body">
<label class="f-field"><span class="f-label">제목</span>
<input
v-model="title"
class="f-input"
placeholder="일정 제목"
autofocus
</label>
<div class="form-row">
<label class="form-field"><span class="form-label">시작일</span>
<input
v-model="s"
type="date"
class="form-input"
>
</label>
<label class="form-field"><span class="form-label">종료일</span>
<input
v-model="e"
type="date"
class="form-input"
:min="s"
>
</label>
</div>
<div class="form-field">
<span class="form-label">시간</span>
<div class="time-row">
<input
v-model="startT"
type="time"
class="form-input time-input"
>
<span class="time-dash"></span>
<input
v-model="endT"
type="time"
class="form-input time-input"
>
</div>
</div>
<label class="form-field"><span class="form-label">장소</span>
<input
v-model="place"
class="form-input"
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">
<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="msOpen"
class="ms-panel"
>
<button
type="button"
class="ms-item"
:class="{ on: allHands }"
@click="toggleAll"
>
</label>
<label class="f-field"><span class="f-label">유형</span>
<div class="f-select-wrap">
<select
v-model="categoryId"
class="f-select"
<span class="ms-all"></span>
<span class="ms-name"> 직원</span>
<span
v-if="allHands"
class="ms-check"
>
<option
v-for="c in ordered"
:key="c.id"
:value="c.id"
>
{{ c.label }}
</option>
</select>
<span class="f-select-chev">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m6 9 6 6 6-6" /></svg>
><path d="M20 6 9 17l-5-5" /></svg>
</span>
</div>
</label>
<div class="f-row">
<label class="f-field"><span class="f-label">시작일</span>
<input
v-model="s"
type="date"
class="f-input"
>
</label>
<label class="f-field"><span class="f-label">종료일</span>
<input
v-model="e"
type="date"
class="f-input"
:min="s"
>
</label>
</div>
<div class="f-field">
<span class="f-label">시간</span>
<div class="time-row">
<input
v-model="startT"
type="time"
class="f-input time-input"
>
<span class="time-dash"></span>
<input
v-model="endT"
type="time"
class="f-input time-input"
>
</div>
</div>
<label class="f-field"><span class="f-label">장소</span>
<input
v-model="place"
class="f-input"
placeholder="장소 또는 화상"
</button>
<div class="ms-div" />
<button
v-for="p in people"
:key="p.id"
type="button"
class="ms-item"
:class="{ on: attendeeIds.includes(p.id) }"
@click="togglePerson(p.id)"
>
</label>
<div class="f-field">
<span class="f-label">참석자</span>
<ScheduleAvatar
: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>
</button>
<div
ref="msRef"
class="ms"
v-if="people.length === 0"
class="ms-empty"
>
<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">
<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="msOpen"
class="ms-panel"
>
<button
type="button"
class="ms-item"
:class="{ on: allHands }"
@click="toggleAll"
>
<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>
</button>
<div class="ms-div" />
<button
v-for="p in people"
:key="p.id"
type="button"
class="ms-item"
:class="{ on: attendeeIds.includes(p.id) }"
@click="togglePerson(p.id)"
>
<ScheduleAvatar
: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>
</button>
<div
v-if="people.length === 0"
class="ms-empty"
>
등록된 사용자가 없습니다
</div>
</div>
등록된 사용자가 없습니다
</div>
</div>
<label class="f-field"><span class="f-label">설명</span>
<textarea
v-model="desc"
class="f-input f-textarea"
placeholder="메모"
rows="3"
/>
</label>
</div>
<div class="modal-foot">
<button
class="mbtn"
@click="emit('close')"
>
취소
</button>
<button
class="mbtn primary"
:disabled="!valid"
@click="submit"
>
{{ isEdit ? '저장' : '추가' }}
</button>
</div>
</div>
</div>
<label class="form-field"><span class="form-label">설명</span>
<textarea
v-model="desc"
class="form-input form-textarea"
placeholder="메모"
rows="3"
/>
</label>
<template #foot>
<button
class="mbtn"
@click="emit('close')"
>
취소
</button>
<button
class="mbtn primary"
:disabled="!valid"
@click="submit"
>
{{ isEdit ? '저장' : '추가' }}
</button>
</template>
</BaseModal>
</template>
<style scoped>
/* 사이트 공통 폼 모달 규격(멤버 초대 모달 + relay.css 토큰)에 맞춤 — rem 단위 */
.modal-back {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(20, 24, 33, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.modal {
width: 100%;
max-width: 33.75rem;
max-height: 90vh;
overflow-y: auto;
background: #fff;
border-radius: 0.875rem;
box-shadow: 0 24px 64px rgba(20, 24, 33, 0.32);
display: flex;
flex-direction: column;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.375rem 1.5rem 1rem;
position: sticky;
top: 0;
background: #fff;
border-radius: 0.875rem 0.875rem 0 0;
}
.modal-head h3 {
font-size: 1.0625rem;
font-weight: 700;
letter-spacing: -0.01875rem;
}
.modal-x {
width: 2rem;
height: 2rem;
border: none;
background: transparent;
color: var(--text-3);
border-radius: var(--radius-sm);
cursor: pointer;
display: grid;
place-items: center;
margin-right: -0.3125rem;
}
.modal-x:hover {
background: #f1f2f4;
color: var(--text);
}
.modal-x svg {
width: 1.125rem;
height: 1.125rem;
}
.modal-body {
padding: 0.5rem 1.5rem 0.75rem;
display: flex;
flex-direction: column;
gap: 1.125rem;
/* 전역 .modal-body(확인 다이얼로그용)의 가운데 정렬을 폼 모달에선 왼쪽으로 */
text-align: left;
}
.modal-foot {
display: flex;
justify-content: flex-end;
gap: 0.5625rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
background: #fafbfc;
position: sticky;
bottom: 0;
border-radius: 0 0 0.875rem 0.875rem;
}
.f-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.f-label {
font-size: 0.781rem;
font-weight: 600;
color: var(--text-2);
}
.f-input {
width: 100%;
height: 2.5rem;
padding: 0 0.75rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
background: #fff;
}
.f-input::placeholder {
color: var(--text-3);
}
.f-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.f-textarea {
height: auto;
padding: 0.625rem 0.75rem;
resize: vertical;
line-height: 1.5;
min-height: 5.75rem;
}
.f-row {
display: flex;
gap: 0.75rem;
}
.f-row .f-field {
flex: 1;
min-width: 0;
}
.f-select-wrap {
position: relative;
}
.f-select {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 2.5rem;
padding: 0 2.125rem 0 0.75rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
background: #fff;
cursor: pointer;
}
.f-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.f-select-chev {
position: absolute;
right: 0.625rem;
top: 50%;
transform: translateY(-50%);
display: grid;
color: var(--text-3);
pointer-events: none;
}
.f-select-chev svg {
width: 0.9375rem;
height: 0.9375rem;
}
/* 시간 입력 행 */
.time-row {
display: flex;
align-items: center;
@@ -505,6 +321,8 @@ function submit(): void {
.time-dash {
color: var(--text-3);
}
/* 참석자 멀티셀렉트 */
.ms {
position: relative;
}
@@ -600,7 +418,7 @@ function submit(): void {
height: 0.9375rem;
}
.ms-all {
/* 참석자 아바타(ScheduleAvatar :size="20" = 20px)와 동일 크기로 고정 */
/* 참석자 아바타(ScheduleAvatar :size="20" = 20px)와 동일 크기 */
width: 20px;
height: 20px;
border-radius: 50%;
@@ -623,20 +441,4 @@ function submit(): void {
color: var(--text-3);
text-align: center;
}
/* 푸터 버튼 — 전역 .mbtn(확인 다이얼로그용 flex:1) 대신 폼 모달용 우측정렬 컴팩트(멤버 초대 모달과 동일).
색/서체/테두리는 전역 .mbtn / .mbtn.primary 가 담당한다. */
.mbtn {
flex: 0 0 auto;
height: 2.375rem;
padding: 0 1rem;
}
.mbtn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.mbtn.primary:disabled,
.mbtn.primary:disabled:hover {
background: var(--accent);
border-color: var(--accent);
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 유형 필터 + 카테고리 관리 드롭다운
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { computed, ref } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
import type { ApiScheduleCategory } from '@/types/schedule'
const props = defineProps<{
@@ -18,14 +19,7 @@ const emit = defineEmits<{
const open = ref(false)
const rootRef = ref<HTMLElement | null>(null)
function onDoc(e: MouseEvent): void {
if (rootRef.value && !rootRef.value.contains(e.target as Node)) open.value = false
}
watch(open, (v) => {
if (v) document.addEventListener('mousedown', onDoc)
else document.removeEventListener('mousedown', onDoc)
})
onBeforeUnmount(() => document.removeEventListener('mousedown', onDoc))
useClickOutside(open, () => (open.value = false), [rootRef], { esc: false })
const ordered = computed(() =>
[...props.categories].sort((a, b) => a.sortOrder - b.sortOrder),
@@ -0,0 +1,54 @@
import { onBeforeUnmount, onMounted, type Ref } from 'vue'
// 바깥 클릭 / ESC / 스크롤 시 닫기 공용 훅.
// 드롭다운·팝오버·메뉴 등에서 반복되던 document 리스너 등록/해제 로직을 한 곳으로 모은다.
//
// - isOpen: 열림 상태 ref (닫혀 있으면 아무 동작 안 함)
// - close: 닫기 콜백
// - containers: "내부"로 간주할 엘리먼트 ref 목록. 클릭이 이 안이면 무시한다
// (Teleport 로 body 에 띄운 메뉴는 트리거와 메뉴 ref 를 모두 넣는다)
// - opts.event: 바깥 클릭 감지 이벤트 (기본 'mousedown')
// - opts.esc: Escape 로 닫을지 (기본 true)
// - opts.closeOnScroll: 스크롤/리사이즈 시 닫을지 — fixed 로 띄운 메뉴 위치 어긋남 방지 (기본 false)
export function useClickOutside(
isOpen: Ref<boolean>,
close: () => void,
containers: Ref<HTMLElement | null>[],
opts: {
event?: 'mousedown' | 'click'
esc?: boolean
closeOnScroll?: boolean
} = {},
): void {
const { event = 'mousedown', esc = true, closeOnScroll = false } = opts
function onPointer(e: Event): void {
if (!isOpen.value) return
const t = e.target as Node
if (containers.some((r) => r.value?.contains(t))) return
close()
}
function onKey(e: KeyboardEvent): void {
if (isOpen.value && e.key === 'Escape') close()
}
function onScroll(): void {
if (isOpen.value) close()
}
onMounted(() => {
document.addEventListener(event, onPointer, true)
if (esc) document.addEventListener('keydown', onKey)
if (closeOnScroll) {
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
}
})
onBeforeUnmount(() => {
document.removeEventListener(event, onPointer, true)
if (esc) document.removeEventListener('keydown', onKey)
if (closeOnScroll) {
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onScroll)
}
})
}
@@ -0,0 +1,58 @@
import { nextTick, ref } from 'vue'
import { useClickOutside } from './useClickOutside'
// Teleport(body) + fixed 위치로 띄우는 드롭다운 메뉴 공용 훅.
// 트리거 기준 좌표 계산 · open 토글 · 바깥클릭/ESC/스크롤 닫기를 한 곳으로 모은다.
// (DropdownSelect, StatusSelect 등 스크롤/overflow 컨테이너 안에서도 안 잘리는 메뉴에 사용)
//
// - align: 트리거 좌/우 끝 기준 정렬 (기본 'left')
// - matchWidth: 메뉴 최소 너비를 트리거 너비에 맞출지 (기본 false)
// - gap: 트리거 하단과 메뉴 사이 간격(px) (기본 6)
// - esc: ESC 로 닫을지 (기본 true)
export function useDropdownMenu(
opts: {
align?: 'left' | 'right'
matchWidth?: boolean
gap?: number
esc?: boolean
} = {},
) {
const { align = 'left', matchWidth = false, gap = 6, esc = true } = opts
const open = ref(false)
const triggerRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(null)
// 메뉴 fixed 위치(트리거 기준 계산)
const menuStyle = ref<Record<string, string>>({})
function position(): void {
const el = triggerRef.value
if (!el) return
const r = el.getBoundingClientRect()
const style: Record<string, string> = { top: `${r.bottom + gap}px` }
if (matchWidth) style.minWidth = `${r.width}px`
if (align === 'right') {
style.right = `${Math.max(0, window.innerWidth - r.right)}px`
} else {
style.left = `${r.left}px`
}
menuStyle.value = style
}
function toggle(): void {
open.value = !open.value
if (open.value) void nextTick(position)
}
function close(): void {
open.value = false
}
// 바깥클릭(트리거/메뉴 외부)·ESC·스크롤(위치 어긋남) 닫기
useClickOutside(open, close, [triggerRef, menuRef], {
event: 'click',
esc,
closeOnScroll: true,
})
return { open, triggerRef, menuRef, menuStyle, toggle, close, position }
}
+10 -28
View File
@@ -7,6 +7,8 @@ import { RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import DropdownSelect from '@/components/DropdownSelect.vue'
import LoadMore from '@/components/common/LoadMore.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import { useMyTaskStore } from '@/stores/my-task.store'
import { useProjectStore } from '@/stores/project.store'
import { countActive, type MyTaskRow } from '@/mock/relay.mock'
@@ -215,18 +217,12 @@ function loadMore() {
</div>
<!-- 로딩 / 상태 -->
<div
v-if="loading"
class="state-msg"
>
<EmptyState v-if="loading">
업무를 불러오는
</div>
<div
v-else-if="isEmpty"
class="state-msg"
>
</EmptyState>
<EmptyState v-else-if="isEmpty">
{{ view === 'assigned' ? '담당 중인 업무가 없습니다.' : '지시한 업무가 없습니다.' }}
</div>
</EmptyState>
<!-- 상태 그룹 -->
<div
@@ -412,15 +408,11 @@ function loadMore() {
</div>
<!-- 더보기 -->
<button
<LoadMore
v-if="hasMore"
class="load-more"
type="button"
:disabled="loadingMore"
@click="loadMore"
>
{{ loadingMore ? '불러오는 중…' : '더보기' }}
</button>
:loading="loadingMore"
@load="loadMore"
/>
</div>
</AppShell>
</template>
@@ -537,16 +529,6 @@ function loadMore() {
}
/* 로딩/빈 상태 */
.state-msg {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-sm);
padding: 2.5rem 1.25rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-3);
}
/* 상태 그룹 */
.group {
@@ -7,11 +7,20 @@ import ProjectHeader from '@/components/ProjectHeader.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import DropdownSelect from '@/components/DropdownSelect.vue'
import SegmentedTabs from '@/components/common/SegmentedTabs.vue'
import LoadMore from '@/components/common/LoadMore.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import { useProjectStore } from '@/stores/project.store'
import { useActivityStore } from '@/stores/activity.store'
import type { Project, ActivityIcon } from '@/mock/relay.mock'
type Filter = 'all' | 'task' | 'member' | 'setting'
const FILTER_OPTIONS: { value: Filter; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: 'task', label: '업무' },
{ value: 'member', label: '멤버' },
{ value: 'setting', label: '설정' },
]
const route = useRoute()
const projectId = computed(() => String(route.params.projectId))
@@ -139,32 +148,10 @@ const ActIcon = defineComponent({
<!-- 툴바 -->
<div class="toolbar">
<div class="segmented">
<button
:class="{ active: filter === 'all' }"
@click="filter = 'all'"
>
전체
</button>
<button
:class="{ active: filter === 'task' }"
@click="filter = 'task'"
>
업무
</button>
<button
:class="{ active: filter === 'member' }"
@click="filter = 'member'"
>
멤버
</button>
<button
:class="{ active: filter === 'setting' }"
@click="filter = 'setting'"
>
설정
</button>
</div>
<SegmentedTabs
v-model="filter"
:options="FILTER_OPTIONS"
/>
<DropdownSelect
v-model="memberFilter"
:options="memberOptions"
@@ -192,18 +179,12 @@ const ActIcon = defineComponent({
</div>
<!-- 로딩 / 상태 -->
<div
v-if="loading"
class="feed-state"
>
<EmptyState v-if="loading">
활동을 불러오는 중입니다
</div>
<div
v-else-if="isEmpty"
class="feed-state"
>
</EmptyState>
<EmptyState v-else-if="isEmpty">
아직 기록된 활동이 없습니다.
</div>
</EmptyState>
<!-- 활동 피드 -->
<!-- act-text v-html store 에서 사용자 입력을 escapeHtml 이스케이프한 신뢰 태그만 조립한 마크업이다 (XSS 위험 없음) -->
@@ -243,20 +224,12 @@ const ActIcon = defineComponent({
</div>
</div>
<!-- 보기 서버 페이지네이션(누적). 필터와 무관하게 다음 페이지를 불러온다 -->
<div
<!-- 더보기 서버 페이지네이션(누적). 필터와 무관하게 다음 페이지를 불러온다 -->
<LoadMore
v-if="!loading && activityStore.hasMore"
class="more-row"
>
<button
type="button"
class="more-btn"
:disabled="activityStore.loadingMore"
@click="activityStore.loadMore()"
>
{{ activityStore.loadingMore ? '불러오는 중…' : '더 보기' }}
</button>
</div>
:loading="activityStore.loadingMore"
@load="activityStore.loadMore()"
/>
</div>
</AppShell>
</template>
@@ -388,39 +361,4 @@ const ActIcon = defineComponent({
margin-left: 0.75rem;
}
/* 더 보기 */
.more-row {
display: flex;
justify-content: center;
margin-top: 0.875rem;
}
.more-btn {
border: 1px solid var(--border);
background: var(--panel);
border-radius: 0.5rem;
padding: 0.5rem 1.25rem;
font-size: 0.813rem;
font-weight: 600;
color: var(--text-2);
cursor: pointer;
}
.more-btn:hover:not(:disabled) {
background: #f4f5f7;
}
.more-btn:disabled {
opacity: 0.6;
cursor: default;
}
/* 로딩/빈 상태 */
.feed-state {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-sm);
padding: 2.5rem 1.25rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-3);
}
</style>
+28 -63
View File
@@ -9,10 +9,14 @@ import ProjectHeader from '@/components/ProjectHeader.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import DropdownSelect from '@/components/DropdownSelect.vue'
import SegmentedTabs from '@/components/common/SegmentedTabs.vue'
import LoadMore from '@/components/common/LoadMore.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import { useProjectStore } from '@/stores/project.store'
import { useTaskStore } from '@/stores/task.store'
import type { TaskListQuery, TaskSegment, TaskSort } from '@/types/task'
import type { Project, TaskStatus } from '@/mock/relay.mock'
import { taskStatusLabel } from '@/shared/utils/format'
import type { Project } from '@/mock/relay.mock'
const route = useRoute()
const projectId = computed(() => String(route.params.projectId))
@@ -32,6 +36,15 @@ async function loadProject() {
const keyword = ref('')
const segment = ref<TaskSegment>('all')
// 세그먼트 탭 옵션(수량 포함) — counts 갱신에 반응
const segOptions = computed<{ value: TaskSegment; label: string; count: number }[]>(
() => [
{ value: 'all', label: '전체', count: counts.value.all },
{ value: 'prog', label: '진행 중', count: counts.value.prog },
{ value: 'todo', label: '대기', count: counts.value.todo },
{ value: 'done', label: '완료', count: counts.value.done },
],
)
const sort = ref<TaskSort>('latest')
const SORT_OPTIONS: { value: TaskSort; label: string }[] = [
{ value: 'latest', label: '최신순' },
@@ -96,14 +109,7 @@ const isFiltered = computed(() => keyword.value.trim() !== '' || segment.value !
// 지연 건수 — 현재 로드된 목록 기준(전역 정확 집계는 백엔드 미지원, 알려진 한계)
const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
// 업무 상태 → 배지 라벨
const STATUS_LABEL: Record<TaskStatus, string> = {
prog: '진행 중',
review: '승인 대기',
todo: '대기',
done: '완료',
changes: '수정 요청',
}
// 업무 상태 → 배지 라벨은 공용 SSOT(taskStatusLabel) 사용
</script>
<template>
@@ -188,32 +194,10 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
placeholder="업무 검색…"
>
</div>
<div class="segmented">
<button
:class="{ active: segment === 'all' }"
@click="segment = 'all'"
>
전체 <span class="n">{{ counts.all }}</span>
</button>
<button
:class="{ active: segment === 'prog' }"
@click="segment = 'prog'"
>
진행 <span class="n">{{ counts.prog }}</span>
</button>
<button
:class="{ active: segment === 'todo' }"
@click="segment = 'todo'"
>
대기 <span class="n">{{ counts.todo }}</span>
</button>
<button
:class="{ active: segment === 'done' }"
@click="segment = 'done'"
>
완료 <span class="n">{{ counts.done }}</span>
</button>
</div>
<SegmentedTabs
v-model="segment"
:options="segOptions"
/>
<DropdownSelect
v-model="sort"
:options="SORT_OPTIONS"
@@ -254,18 +238,12 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
</div>
<!-- 업무 목록 -->
<div
v-if="loading && tasks.length === 0"
class="state-msg"
>
<EmptyState v-if="loading && tasks.length === 0">
업무를 불러오는
</div>
<div
v-else-if="tasks.length === 0"
class="state-msg"
>
</EmptyState>
<EmptyState v-else-if="tasks.length === 0">
{{ isFiltered ? '조건에 맞는 업무가 없습니다.' : '아직 등록된 업무가 없습니다. 업무를 지시해 보세요.' }}
</div>
</EmptyState>
<div
v-else
class="list"
@@ -399,7 +377,7 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
<span
class="st-badge"
:class="task.status"
>{{ STATUS_LABEL[task.status] }}</span>
>{{ taskStatusLabel(task.status) }}</span>
<span class="chev">
<svg
viewBox="0 0 24 24"
@@ -417,15 +395,11 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
</div>
<!-- 더보기 -->
<button
<LoadMore
v-if="hasMore"
class="load-more"
type="button"
:disabled="loadingMore"
@click="loadMoreTasks()"
>
{{ loadingMore ? '불러오는 중…' : '더보기' }}
</button>
:loading="loadingMore"
@load="loadMoreTasks"
/>
</div>
<!-- 프로젝트를 찾지 못한 경우 -->
@@ -591,13 +565,4 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
text-align: center;
color: var(--text-2);
}
.state-msg {
padding: 2.5rem 1.125rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-3);
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
}
</style>
+10 -24
View File
@@ -6,6 +6,8 @@ import { RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import DropdownSelect from '@/components/DropdownSelect.vue'
import LoadMore from '@/components/common/LoadMore.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import { useProjectStore } from '@/stores/project.store'
import type { ProjectListQuery, ProjectSort } from '@/types/project'
@@ -203,30 +205,20 @@ const isFiltered = computed(() => keyword.value.trim() !== '')
</RouterLink>
<!-- 로딩 / 상태 -->
<div
v-if="loading && projects.length === 0"
class="list-empty"
>
<EmptyState v-if="loading && projects.length === 0">
불러오는
</div>
<div
v-else-if="projects.length === 0"
class="list-empty"
>
</EmptyState>
<EmptyState v-else-if="projects.length === 0">
{{ isFiltered ? '검색 결과가 없습니다.' : '아직 프로젝트가 없습니다. 프로젝트를 만들어 보세요.' }}
</div>
</EmptyState>
</div>
<!-- 더보기 -->
<button
<LoadMore
v-if="hasMore"
class="load-more"
type="button"
:disabled="loadingMore"
@click="projectStore.loadMore()"
>
{{ loadingMore ? '불러오는 중…' : '더보기' }}
</button>
:loading="loadingMore"
@load="projectStore.loadMore()"
/>
</div>
</AppShell>
</template>
@@ -371,10 +363,4 @@ const isFiltered = computed(() => keyword.value.trim() !== '')
width: 1.125rem;
height: 1.125rem;
}
.list-empty {
padding: 2.5rem 1.25rem;
text-align: center;
color: var(--text-3);
font-size: 0.844rem;
}
</style>
+138 -317
View File
@@ -7,6 +7,9 @@ import ProjectHeader from '@/components/ProjectHeader.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import DropdownSelect from '@/components/DropdownSelect.vue'
import BaseModal from '@/components/common/BaseModal.vue'
import SegmentedTabs from '@/components/common/SegmentedTabs.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import { useProjectStore } from '@/stores/project.store'
import { useMemberStore } from '@/stores/member.store'
import { useUser } from '@/composables/useUser'
@@ -70,6 +73,15 @@ const counts = computed(() => ({
member: members.value.filter((m) => m.roleType === 'member').length,
}))
// 역할 필터 세그먼트 옵션(수량 포함)
const filterOptions = computed<{ value: RoleFilter; label: string; count: number }[]>(
() => [
{ value: 'all', label: '전체', count: counts.value.all },
{ value: 'admin', label: '관리자', count: counts.value.admin },
{ value: 'member', label: '멤버', count: counts.value.member },
],
)
const filteredMembers = computed(() =>
members.value.filter((m) => {
const matchRole = filter.value === 'all' || m.roleType === filter.value
@@ -285,26 +297,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
placeholder="이름·이메일 검색…"
>
</div>
<div class="segmented">
<button
:class="{ active: filter === 'all' }"
@click="filter = 'all'"
>
전체 <span class="n">{{ counts.all }}</span>
</button>
<button
:class="{ active: filter === 'admin' }"
@click="filter = 'admin'"
>
관리자 <span class="n">{{ counts.admin }}</span>
</button>
<button
:class="{ active: filter === 'member' }"
@click="filter = 'member'"
>
멤버 <span class="n">{{ counts.member }}</span>
</button>
</div>
<SegmentedTabs
v-model="filter"
:options="filterOptions"
/>
<button
v-if="isAdmin"
class="btn primary invite"
@@ -330,18 +326,12 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</div>
<!-- 멤버 목록 -->
<div
v-if="loading"
class="state-msg"
>
<EmptyState v-if="loading">
멤버를 불러오는
</div>
<div
v-else-if="filteredMembers.length === 0"
class="state-msg"
>
</EmptyState>
<EmptyState v-else-if="filteredMembers.length === 0">
표시할 멤버가 없습니다.
</div>
</EmptyState>
<div
v-else
class="list"
@@ -466,134 +456,109 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</div>
<!-- 멤버 초대 모달 -->
<Teleport to="body">
<div
class="modal-backdrop"
:class="{ open: showInvite }"
@click.self="showInvite = false"
>
<div
class="invite-modal"
role="dialog"
aria-modal="true"
>
<div class="modal-head">
<div>
<h2>멤버 초대</h2>
<div class="mh-sub">
<b>{{ repo?.name }}</b> 프로젝트에 멤버를 추가합니다.
</div>
</div>
<button
class="modal-close"
type="button"
@click="showInvite = false"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
<form
class="modal-body"
@submit.prevent="submitInvite"
>
<div class="m-field uc-field">
<label class="m-label">초대할 사람 (이메일)</label>
<input
v-model="inviteEmail"
class="m-input"
type="email"
placeholder="가입된 사용자의 이메일 입력…"
autocomplete="off"
@focus="onEmailFocus"
@input="onEmailInput"
@blur="onEmailBlur"
>
<!-- 사용자 자동완성 드롭다운 -->
<div
v-if="showUserDropdown"
class="user-dd"
>
<div
v-if="usersLoading"
class="user-dd-msg"
>
불러오는
</div>
<template v-else>
<button
v-for="u in userOptions"
:key="u.id"
type="button"
class="user-dd-item"
@mousedown.prevent="selectUser(u)"
>
<span class="avatar user-av"><UserAvatar
:url="u.avatarUrl"
:name="u.name"
/></span>
<span class="user-nm">{{ u.name }}</span>
<span class="user-em">{{ u.email }}</span>
</button>
<div
v-if="userOptions.length === 0"
class="user-dd-msg"
>
{{ inviteEmail.trim() ? '일치하는 사용자가 없습니다.' : '초대할 수 있는 사용자가 없습니다.' }}
</div>
</template>
</div>
</div>
<BaseModal
v-if="showInvite"
title="멤버 초대"
@close="closeInvite"
>
<template #subtitle>
<b>{{ repo?.name }}</b> 프로젝트에 멤버를 추가합니다.
</template>
<div class="m-field">
<label class="m-label">역할</label>
<DropdownSelect
v-model="inviteRole"
:options="ROLE_OPTIONS"
block
class="role-select"
aria-label="역할"
/>
</div>
<div class="role-hint">
<b>멤버</b> 담당자로 배정되어 업무를 수행하고, <b>관리자</b> 업무 지시와 멤버 관리도 있습니다.
</div>
</form>
<div class="modal-foot">
<span class="fnote">이미 가입된 사용자만 초대할 있습니다.</span>
<button
class="mbtn"
type="button"
@click="closeInvite"
<form
class="invite-body"
@submit.prevent="submitInvite"
>
<div class="form-field uc-field">
<label class="form-label">초대할 사람 (이메일)</label>
<input
v-model="inviteEmail"
class="form-input"
type="email"
placeholder="가입된 사용자의 이메일 입력…"
autocomplete="off"
@focus="onEmailFocus"
@input="onEmailInput"
@blur="onEmailBlur"
>
<!-- 사용자 자동완성 드롭다운 -->
<div
v-if="showUserDropdown"
class="user-dd"
>
<div
v-if="usersLoading"
class="user-dd-msg"
>
취소
</button>
<button
class="mbtn primary"
type="button"
:disabled="!canSubmitInvite"
@click="submitInvite"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
{{ inviteBusy ? '초대 중…' : '초대 보내기' }}
</button>
불러오는
</div>
<template v-else>
<button
v-for="u in userOptions"
:key="u.id"
type="button"
class="user-dd-item"
@mousedown.prevent="selectUser(u)"
>
<span class="avatar user-av"><UserAvatar
:url="u.avatarUrl"
:name="u.name"
/></span>
<span class="user-nm">{{ u.name }}</span>
<span class="user-em">{{ u.email }}</span>
</button>
<div
v-if="userOptions.length === 0"
class="user-dd-msg"
>
{{ inviteEmail.trim() ? '일치하는 사용자가 없습니다.' : '초대할 수 있는 사용자가 없습니다.' }}
</div>
</template>
</div>
</div>
</div>
</Teleport>
<div class="form-field">
<label class="form-label">역할</label>
<DropdownSelect
v-model="inviteRole"
:options="ROLE_OPTIONS"
block
class="role-select"
aria-label="역할"
/>
<div class="role-hint">
<b>멤버</b> 담당자로 배정되어 업무를 수행하고, <b>관리자</b> 업무 지시와 멤버 관리도 있습니다.
</div>
</div>
</form>
<template #foot>
<span class="fnote">이미 가입된 사용자만 초대할 있습니다.</span>
<button
class="mbtn"
type="button"
@click="closeInvite"
>
취소
</button>
<button
class="mbtn primary"
type="button"
:disabled="!canSubmitInvite"
@click="submitInvite"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
{{ inviteBusy ? '초대 중…' : '초대 보내기' }}
</button>
</template>
</BaseModal>
</AppShell>
</template>
@@ -800,15 +765,6 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
}
/* 로딩/빈 상태 메시지 */
.state-msg {
padding: 2.5rem 1.125rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-3);
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
}
/* 역할 변경 드롭다운 */
/* 전역 .list 의 overflow:hidden 이 드롭다운을 잘라내므로, 이 화면에서는 보이도록 해제.
@@ -867,73 +823,17 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
<!-- 초대 모달은 Teleport body 렌더되므로 -scoped 전역 스타일 -->
<style>
.invite-modal {
width: 100%;
max-width: 32.75rem;
background: #fff;
border-radius: 0.875rem;
box-shadow: 0 24px 64px rgba(20, 24, 33, 0.32);
/* overflow:hidden 제거 — 자동완성 드롭다운이 모달 밖으로 넘쳐도 잘리지 않도록 */
/* 멤버 초대 모달 — 셸/헤더/푸터/필드는 BaseModal + 전역 .form-* 사용.
본문 폼은 display:contents 로 BaseModal 본문 간격을 상속받고,
고유 요소(자동완성 드롭다운/역할 힌트/푸터 노트)만 여기서 정의.
(BaseModal 이 body 로 Teleport 하므로 비-scoped 전역 스타일) */
.invite-body {
display: contents;
}
.invite-modal .modal-head {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1.375rem 1.5rem 1rem;
}
.invite-modal .modal-head h2 {
font-size: 1.125rem;
font-weight: 700;
letter-spacing: -0.01875rem;
}
.invite-modal .mh-sub {
font-size: 0.8125rem;
color: var(--text-2);
margin-top: 0.25rem;
}
.invite-modal .mh-sub b {
color: var(--text-2);
font-weight: 600;
}
.invite-modal .modal-close {
margin-left: auto;
width: 2rem;
height: 2rem;
border: none;
background: transparent;
color: var(--text-3);
border-radius: var(--radius-sm);
display: grid;
place-items: center;
cursor: pointer;
flex-shrink: 0;
}
.invite-modal .modal-close:hover {
background: #f1f2f4;
color: var(--text);
}
.invite-modal .modal-close svg {
width: 1.125rem;
height: 1.125rem;
}
.invite-modal .modal-body {
padding: 0.25rem 1.5rem 0.5rem;
/* 전역 .modal-body(text-align:center) 상속 차단 — 폼은 좌측 정렬 */
text-align: left;
}
/* 모달 입력 필드(이메일/역할) — 세로 스택, 전체 너비 */
.invite-modal .m-field {
margin-top: 1.125rem;
}
.invite-modal .m-field:first-child {
margin-top: 0;
}
/* 사용자 자동완성 드롭다운 */
.invite-modal .uc-field {
.invite-body .uc-field {
position: relative;
}
.invite-modal .user-dd {
.invite-body .user-dd {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
@@ -947,7 +847,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
max-height: 16rem;
overflow-y: auto;
}
.invite-modal .user-dd-item {
.invite-body .user-dd-item {
display: flex;
align-items: center;
gap: 0.625rem;
@@ -960,22 +860,22 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
font-family: inherit;
text-align: left;
}
.invite-modal .user-dd-item:hover {
.invite-body .user-dd-item:hover {
background: #f5f6f8;
}
.invite-modal .user-av {
.invite-body .user-av {
width: 1.625rem;
height: 1.625rem;
font-size: 0.6875rem;
flex-shrink: 0;
}
.invite-modal .user-nm {
.invite-body .user-nm {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.invite-modal .user-em {
.invite-body .user-em {
font-size: 0.75rem;
color: var(--text-3);
margin-left: auto;
@@ -985,111 +885,32 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
max-width: 14rem;
padding-left: 0.5rem;
}
.invite-modal .user-dd-msg {
.invite-body .user-dd-msg {
padding: 0.75rem 0.5rem;
font-size: 0.781rem;
color: var(--text-3);
text-align: center;
}
.invite-modal .m-label {
font-size: 0.781rem;
font-weight: 600;
color: var(--text-2);
margin-bottom: 0.5rem;
display: block;
}
.invite-modal .m-label .muted {
color: var(--text-3);
font-weight: 500;
}
/* 역할 선택 드롭다운(DropdownSelect) — 모달 폼 입력 높이/서체에 맞춤 */
.invite-modal .role-select :deep(.dd-trigger) {
.invite-body .role-select .dd-trigger {
height: 2.5rem;
font-size: 0.844rem;
font-weight: 600;
color: var(--text);
}
.invite-modal .m-input {
width: 100%;
height: 2.5rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0 0.75rem;
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
background: #fff;
}
.invite-modal .m-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.invite-modal .m-input::placeholder {
color: var(--text-3);
}
.invite-modal .role-hint {
.invite-body .role-hint {
font-size: 0.75rem;
color: var(--text-3);
margin-top: 0.5rem;
line-height: 1.5;
}
.invite-modal .role-hint b {
.invite-body .role-hint b {
color: var(--text-2);
font-weight: 600;
}
.invite-modal .modal-foot {
display: flex;
align-items: center;
gap: 0.5625rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
background: #fafbfc;
margin-top: 1rem;
/* overflow:hidden 제거에 따른 하단 라운드 보정 */
border-radius: 0 0 0.875rem 0.875rem;
}
.invite-modal .modal-foot .fnote {
/* 푸터 좌측 안내 문구(BaseModal 푸터 슬롯) */
.fnote {
font-size: 0.75rem;
color: var(--text-3);
margin-right: auto;
}
.invite-modal .mbtn {
/* 전역 .mbtn(flex:1, 확인 다이얼로그용) 상속 차단 — 버튼은 내용 너비로 우측 정렬 */
flex: 0 0 auto;
height: 2.375rem;
padding: 0 1rem;
border-radius: var(--radius);
font-size: 0.844rem;
font-weight: 600;
border: 1px solid var(--border-strong);
background: #fff;
color: var(--text-2);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-family: inherit;
}
.invite-modal .mbtn:hover {
background: #f1f2f4;
color: var(--text);
}
.invite-modal .mbtn.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
}
.invite-modal .mbtn.primary:hover {
background: var(--accent-hover);
}
.invite-modal .mbtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.invite-modal .mbtn svg {
width: 0.9375rem;
height: 0.9375rem;
}
</style>
+5 -10
View File
@@ -31,6 +31,10 @@ import { useDialog } from '@/composables/useDialog'
import UserAvatar from '@/components/UserAvatar.vue'
import StatusSelect from '@/components/StatusSelect.vue'
import { attachmentKindOf, fileBadge } from '@/shared/utils/format'
import {
WORK_STATUS_META,
WORK_STATUS_ORDER,
} from '@/shared/constants/workStatus'
const route = useRoute()
const router = useRouter()
@@ -302,16 +306,7 @@ const isAssignee = computed(
/* ============================================================
* 중간 점검(체크포인트) — 작업자가 점검일 당일에 보고서로 전송, 지시자는 기록 열람
* ============================================================ */
const WORK_STATUS_ORDER: ChecklistWorkStatus[] = ['todo', 'doing', 'done']
// 진행 상태 표시 메타(라벨/색/약색)
const WORK_STATUS_META: Record<
ChecklistWorkStatus,
{ label: string; color: string; weak: string }
> = {
todo: { label: '대기', color: '#9298a3', weak: '#eef0f2' },
doing: { label: '진행 중', color: '#2563eb', weak: '#e9f0fd' },
done: { label: '완료', color: '#1f9d57', weak: '#e7f5ed' },
}
// 진행 상태 메타/순서는 공용 SSOT(shared/constants/workStatus) 사용
// 카테고리 색(탭/그룹 dot)
const CAT_COLOR: Record<ChecklistCategory, string> = {
필수: '#2563eb',
@@ -0,0 +1,18 @@
import type { ChecklistWorkStatus } from '@/mock/relay.mock'
// 작업자 진행상태(대기/진행 중/완료) 라벨·색 — 단일 진실 공급원(SSOT).
// StatusSelect(드롭다운)·업무 상세(보고서)에서 공통 사용한다.
export interface WorkStatusMeta {
label: string
color: string
weak: string
}
export const WORK_STATUS_META: Record<ChecklistWorkStatus, WorkStatusMeta> = {
todo: { label: '대기', color: '#9298a3', weak: '#eef0f2' },
doing: { label: '진행 중', color: '#2563eb', weak: '#e9f0fd' },
done: { label: '완료', color: '#1f9d57', weak: '#e7f5ed' },
}
// 표시 순서(대기 → 진행 중 → 완료)
export const WORK_STATUS_ORDER: ChecklistWorkStatus[] = ['todo', 'doing', 'done']
+1 -1
View File
@@ -101,7 +101,7 @@ const WEEKDAYS_KO = ['일', '월', '화', '수', '목', '금', '토']
/** 업무 상태 → 한국어 라벨 */
const TASK_STATUS_LABEL: Record<TaskStatus, string> = {
todo: '할 일',
todo: '대기',
prog: '진행 중',
review: '승인 대기',
done: '완료',