Compare commits

..

10 Commits

Author SHA1 Message Date
ttipo 7568637de2 style: 보고서 모달·일정 화면 다듬기 + 일정 컴포넌트 rem 통일
- 보고서 모달: '전체' 탭 제거(기본=항목 있는 첫 분류), 상태 드롭다운 크기 축소,
  푸터 배경을 다른 모달(.bm-foot)과 동일한 #fafbfc 로 통일
- 일정 화면: 콘텐츠 영역 상하좌우 여백 1.5rem 통일, 툴바 가로 여백 정렬 +
  전역 .toolbar 의 margin-bottom 상속 차단(상단 여분 마진 제거)
- 일정 하위 컴포넌트 px→rem 일괄 변환(보더 width·box-shadow 는 px 유지),
  ScheduleAvatar 도 size 를 rem 렌더 → '전 직원' 표식과 아바타 크기 정합 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:38:59 +09:00
ttipo 61d247de08 Merge: 프론트 공용화 리팩터 (Phase 0-4)
refactor/frontend-commonization 머지 — useClickOutside/useDropdownMenu 훅,
BaseModal/SegmentedTabs/LoadMore/EmptyState 공용 컴포넌트, .form-*/.empty-state
전역 클래스, 작업상태 SSOT(workStatus) + 업무상태 'todo' 라벨 '대기' 통일.
2026-06-23 00:10:16 +09:00
ttipo ddf94b188b Merge: 전사 공유 일정(주간 캘린더) 기능
feature/schedule-calendar 머지 — 백엔드 Schedule 모듈 + 프론트 일정 화면
(주간 타임라인·카테고리/일정 CRUD·상세 패널·모달) + UI 다듬기.
공용화 리팩터(refactor/frontend-commonization)는 별도 브랜치로 분리 유지.
2026-06-23 00:09:12 +09:00
ttipo daff61c85e refactor: 업무 상태 'todo' 라벨을 '대기' 로 통일 (Phase 4)
todo 표기가 화면마다 '할 일'(taskStatusLabel) vs '대기'(ProjectDetail 배지)로
갈리던 불일치 해소. SSOT(format.taskStatusLabel)를 '대기' 로 통일하고
ProjectDetailPage 의 자체 STATUS_LABEL 제거 → taskStatusLabel 사용.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:02:42 +09:00
ttipo 3380bbdcff refactor: 작업자 진행상태(대기/진행중/완료) 라벨·색 SSOT 통합 (Phase 4)
StatusSelect 와 업무 상세에 각각 중복 정의되던 WORK_STATUS_META/ORDER 를
shared/constants/workStatus.ts 단일 소스로 추출(값 동일, 표시 변화 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:00:21 +09:00
ttipo 21cabbe20b refactor: ScheduleAvatar 를 UserAvatar 어댑터로 정리 (Phase 3-4)
ScheduleAvatar 가 복제하던 이미지/실루엣 렌더링을 제거하고, 공용 UserAvatar 를
size(px)로 감싸는 얇은 래퍼로 전환. 아바타 표시 로직을 UserAvatar 단일 소스로 통일
(.sa 클래스는 상세 패널 스택 CSS 훅으로 유지, 사용처 변경 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:57:22 +09:00
ttipo d3e2861440 refactor: 빈/상태 메시지 박스 EmptyState 로 통일 (Phase 3-3)
목록 로딩·빈 상태 박스(state-msg/feed-state/list-empty)를 전역 .empty-state
카드 스타일 + EmptyState 컴포넌트로 통일. 5개 페이지(프로젝트 목록/상세,
내 업무, 활동, 멤버)의 중복 박스 CSS 제거.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:56:07 +09:00
ttipo e7760e9ddc refactor: 더보기 버튼 LoadMore 컴포넌트로 통일 (Phase 3-2)
목록 페이지네이션 '더보기' 버튼 마크업/문구를 LoadMore 로 추출(전역 .load-more 사용).
- 프로젝트 목록/상세, 내 업무, 프로젝트 활동 4곳 적용
- 활동 화면의 비표준 .more-row/.more-btn 제거하고 표준 .load-more 로 통일
  ('더 보기' → '더보기' 문구도 통일)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:51:45 +09:00
ttipo d3a916dbd7 refactor: 세그먼트 토글 SegmentedTabs 컴포넌트로 통일 (Phase 3-1)
3개 페이지(활동/프로젝트 상세/멤버)에 반복되던 .segmented 버튼 마크업을
options 기반 SegmentedTabs 로 추출(전역 .segmented 스타일은 그대로 사용).
수량(.n) 표기는 option.count 로 처리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:49:09 +09:00
ttipo 7425d57134 refactor: Teleport 드롭다운 위치/토글 로직 useDropdownMenu 로 통합 (Phase 2)
DropdownSelect·StatusSelect 에 중복되던 fixed 위치 계산·open 토글·
바깥클릭/ESC/스크롤 닫기 로직을 useDropdownMenu 훅으로 추출.

- composables/useDropdownMenu.ts 신설 (align/matchWidth/gap/esc 옵션)
- DropdownSelect, StatusSelect 가 훅을 사용하도록 정리
- 절대배치 메뉴(일정 멀티셀렉트/유형필터/참석자 스택)는 Phase 0 useClickOutside 로 이미 공유

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:44:48 +09:00
21 changed files with 445 additions and 587 deletions
+14
View File
@@ -677,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 -38
View File
@@ -1,8 +1,8 @@
<script setup lang="ts" generic="T extends string">
// 커스텀 드롭다운(셀렉트) — 네이티브 select 대신 디자인 토큰에 맞춘 정렬/필터 공통 UI.
// 메뉴는 body 로 Teleport + fixed 위치로 띄워, 스크롤/overflow 컨테이너 안에서도 잘리지 않는다.
import { computed, nextTick, ref } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
import { computed } from 'vue'
import { useDropdownMenu } from '@/composables/useDropdownMenu'
interface Option {
value: T
@@ -24,55 +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·스크롤(위치 어긋남) 시 닫기 — 공용 훅
useClickOutside(open, () => (open.value = false), [root, menuRef], {
event: 'click',
esc: true,
closeOnScroll: true,
})
</script>
<template>
<div
ref="root"
class="dd"
:class="{ push: pushRight, block }"
>
+22 -42
View File
@@ -1,48 +1,28 @@
<script setup lang="ts">
// 진행 상태(대기/진행 중/완료) 선택 드롭다운 — 상태색 테마. 메뉴는 body Teleport(스크롤 클리핑 방지).
import { computed, nextTick, ref } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
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()
}
// 바깥 클릭·스크롤(위치 어긋남) 시 닫기 — 공용 훅(Teleport 메뉴라 트리거+메뉴 모두 내부로)
useClickOutside(open, () => (open.value = false), [triggerRef, menuRef], {
event: 'click',
esc: false,
closeOnScroll: true,
})
</script>
<template>
@@ -110,16 +90,16 @@ useClickOutside(open, () => (open.value = false), [triggerRef, menuRef], {
.ss-btn {
display: inline-flex;
align-items: center;
gap: 7px;
height: 30px;
min-width: 100px;
padding: 0 9px 0 11px;
gap: 6px;
height: 26px;
min-width: 86px;
padding: 0 8px 0 9px;
border: 1px solid var(--border-strong);
border-radius: 8px;
border-radius: 7px;
background: var(--w);
color: var(--c);
font-family: inherit;
font-size: 0.781rem;
font-size: 0.719rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
@@ -128,8 +108,8 @@ useClickOutside(open, () => (open.value = false), [triggerRef, menuRef], {
border-color: var(--c);
}
.ss-dot {
width: 8px;
height: 8px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--c);
flex-shrink: 0;
@@ -139,8 +119,8 @@ useClickOutside(open, () => (open.value = false), [triggerRef, menuRef], {
text-align: left;
}
.ss-chev {
width: 14px;
height: 14px;
width: 13px;
height: 13px;
}
.ss-menu {
position: fixed;
@@ -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,7 +1,10 @@
<script setup lang="ts">
// 일정 참석자 아바타 — 사이트 공용 UserAvatar 와 동일한 방식.
// 프로필 이미지가 있으면 사진, 없으면 사람 실루엣 placeholder(색상+이니셜 방식은 폐기).
// 일정 참석자 아바타 — 사이트 공용 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<{
@@ -11,8 +14,7 @@ const props = withDefaults(
}>(),
{ name: '', avatarUrl: null, size: 21 },
)
const dim = computed(() => `${props.size}px`)
const dim = computed(() => `${props.size / 16}rem`)
</script>
<template>
@@ -20,43 +22,17 @@ const dim = computed(() => `${props.size}px`)
class="sa"
:style="{ width: dim, height: dim }"
>
<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>
@@ -227,7 +227,7 @@ const extra = computed(() =>
top: 0;
right: 0;
bottom: 0;
width: 360px;
width: 22.5rem;
z-index: 30;
border-left: 1px solid var(--border);
background: var(--panel);
@@ -242,15 +242,15 @@ const extra = computed(() =>
transform: translateX(0);
}
.type-chip {
font-size: 11px;
font-size: 0.6875rem;
font-weight: 600;
padding: 2px 9px;
border-radius: 6px;
padding: 0.125rem 0.5625rem;
border-radius: 0.375rem;
white-space: nowrap;
line-height: 1.6;
}
.dt-head {
padding: 20px 22px 18px;
padding: 1.25rem 1.375rem 1.125rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
@@ -260,8 +260,8 @@ const extra = computed(() =>
justify-content: space-between;
}
.dt-close {
width: 30px;
height: 30px;
width: 1.875rem;
height: 1.875rem;
border-radius: var(--radius-sm);
border: none;
background: transparent;
@@ -269,48 +269,48 @@ const extra = computed(() =>
cursor: pointer;
display: grid;
place-items: center;
margin-right: -6px;
margin-right: -0.375rem;
}
.dt-close:hover {
background: #f1f2f4;
color: var(--text);
}
.dt-close svg {
width: 17px;
height: 17px;
width: 1.0625rem;
height: 1.0625rem;
}
.dt-head h2 {
font-size: 18px;
font-size: 1.125rem;
font-weight: 700;
letter-spacing: -0.3px;
letter-spacing: -0.0187rem;
line-height: 1.4;
margin: 13px 0 8px;
margin: 0.8125rem 0 0.5rem;
word-break: keep-all;
}
.dt-date {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
gap: 0.4375rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-2);
}
.dt-date svg {
width: 15px;
height: 15px;
width: 0.9375rem;
height: 0.9375rem;
color: var(--text-3);
}
.dt-dur {
font-size: 11px;
font-size: 0.6875rem;
font-weight: 700;
color: var(--accent);
background: var(--accent-weak);
border-radius: 5px;
padding: 1px 7px;
border-radius: 0.3125rem;
padding: 0.0625rem 0.4375rem;
white-space: nowrap;
}
.dt-body {
padding: 18px 22px 24px;
padding: 1.125rem 1.375rem 1.5rem;
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -318,25 +318,25 @@ const extra = computed(() =>
.dt-list {
display: flex;
flex-direction: column;
gap: 16px;
gap: 1rem;
}
.dt-item {
display: flex;
gap: 14px;
gap: 0.875rem;
align-items: flex-start;
}
.dt-k {
font-size: 12px;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-3);
width: 52px;
width: 3.25rem;
flex-shrink: 0;
padding-top: 2px;
padding-top: 0.125rem;
}
.dt-v {
flex: 1;
min-width: 0;
font-size: 13.5px;
font-size: 0.8438rem;
font-weight: 500;
color: var(--text);
word-break: keep-all;
@@ -349,18 +349,18 @@ const extra = computed(() =>
}
.att-stack {
position: relative;
margin: -3px 0;
margin: -0.1875rem 0;
}
.att-trigger {
display: inline-flex;
align-items: center;
gap: 9px;
gap: 0.5625rem;
border: none;
background: none;
cursor: pointer;
padding: 5px 8px 5px 5px;
margin: -5px -8px -5px -5px;
border-radius: 9px;
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 {
@@ -372,19 +372,19 @@ const extra = computed(() =>
}
.avstack :deep(.sa),
.avstack .av-more {
margin-left: -9px;
margin-left: -0.5625rem;
box-shadow: 0 0 0 2px var(--panel);
}
.avstack :deep(.sa):first-child {
margin-left: 0;
}
.av-more {
width: 30px;
height: 30px;
width: 1.875rem;
height: 1.875rem;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 12px;
font-size: 0.75rem;
font-weight: 700;
background: #eceef1;
color: var(--text-2);
@@ -396,36 +396,36 @@ const extra = computed(() =>
transition: transform 0.15s ease;
}
.att-chev svg {
width: 15px;
height: 15px;
width: 0.9375rem;
height: 0.9375rem;
}
.att-trigger.open .att-chev {
transform: rotate(180deg);
}
.att-panel {
position: absolute;
top: calc(100% + 6px);
top: calc(100% + 0.375rem);
left: 0;
min-width: 200px;
max-width: 260px;
max-height: 240px;
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: 10px;
border-radius: 0.625rem;
box-shadow: var(--shadow-md);
padding: 5px;
padding: 0.3125rem;
}
.att-row {
display: flex;
align-items: center;
gap: 9px;
font-size: 13px;
gap: 0.5625rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text);
padding: 6px 8px;
border-radius: 6px;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
white-space: nowrap;
}
.att-row:hover {
@@ -433,27 +433,27 @@ const extra = computed(() =>
}
.dt-foot {
flex-shrink: 0;
padding: 14px 22px;
padding: 0.875rem 1.375rem;
border-top: 1px solid var(--border);
background: var(--panel);
}
.dt-actions {
display: flex;
gap: 8px;
gap: 0.5rem;
}
.dt-actions .btn {
flex: 1;
justify-content: center;
height: 36px;
height: 2.25rem;
}
.dt-actions .btn.icon-only {
flex: 0 0 auto;
padding: 0;
width: 36px;
width: 2.25rem;
}
.btn {
border-radius: var(--radius);
font-size: 13.5px;
font-size: 0.8438rem;
font-weight: 600;
border: 1px solid var(--border-strong);
background: var(--panel);
@@ -461,7 +461,7 @@ const extra = computed(() =>
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
gap: 0.375rem;
line-height: 1;
white-space: nowrap;
}
@@ -470,8 +470,8 @@ const extra = computed(() =>
color: var(--text);
}
.btn svg {
width: 15px;
height: 15px;
width: 0.9375rem;
height: 0.9375rem;
}
.btn.danger {
background: #fff;
@@ -418,20 +418,20 @@ function submit(): void {
height: 0.9375rem;
}
.ms-all {
/* 참석자 아바타(ScheduleAvatar :size="20" = 20px)와 동일 크기 */
width: 20px;
height: 20px;
/* 참석자 아바타(ScheduleAvatar :size="20" = 1.25rem)와 동일 크기 */
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 9px;
font-size: 0.5625rem;
font-weight: 700;
color: #fff;
background: #9298a3;
flex-shrink: 0;
}
.ms-div {
height: 1px;
height: 0.0625rem;
background: var(--border);
margin: 0.25rem 0.125rem;
}
@@ -222,7 +222,7 @@ function onBodyScroll(): void {
flex-direction: column;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 12px;
border-radius: 0.75rem;
box-shadow: var(--shadow-sm);
}
.tl-head-scroll {
@@ -254,27 +254,27 @@ function onBodyScroll(): void {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 9px 8px;
gap: 0.4375rem;
padding: 0.5625rem 0.5rem;
text-align: center;
border-left: 1px solid var(--border);
}
.tl-dayhead .sdow {
font-size: 12px;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-3);
letter-spacing: 0.2px;
letter-spacing: 0.0125rem;
}
.tl-dayhead .sd {
display: inline-grid;
place-items: center;
min-width: 26px;
height: 26px;
padding: 0 5px;
font-size: 15px;
min-width: 1.625rem;
height: 1.625rem;
padding: 0 0.3125rem;
font-size: 0.9375rem;
font-weight: 700;
letter-spacing: -0.3px;
border-radius: 13px;
letter-spacing: -0.0187rem;
border-radius: 0.8125rem;
}
.tl-dayhead.is-today .sdow {
color: var(--accent);
@@ -301,9 +301,9 @@ function onBodyScroll(): void {
.tl-label {
display: flex;
align-items: center;
gap: 9px;
padding: 0 8px 0 14px;
font-size: 13.5px;
gap: 0.5625rem;
padding: 0 0.5rem 0 0.875rem;
font-size: 0.8438rem;
font-weight: 600;
color: var(--text);
position: sticky;
@@ -314,9 +314,9 @@ function onBodyScroll(): void {
white-space: nowrap;
}
.tl-label .dot {
width: 11px;
height: 11px;
border-radius: 3px;
width: 0.6875rem;
height: 0.6875rem;
border-radius: 0.1875rem;
flex-shrink: 0;
}
.tl-label .ll-name {
@@ -333,12 +333,12 @@ function onBodyScroll(): void {
left: 0;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 11px 14px;
gap: 0.4375rem;
padding: 0.6875rem 0.875rem;
background: var(--panel);
border: none;
font-family: inherit;
font-size: 13px;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-3);
cursor: pointer;
@@ -347,8 +347,8 @@ function onBodyScroll(): void {
color: var(--accent);
}
.tl-addcat svg {
width: 15px;
height: 15px;
width: 0.9375rem;
height: 0.9375rem;
}
.tl-track {
@@ -372,19 +372,19 @@ function onBodyScroll(): void {
.tl-bars {
position: relative;
display: grid;
row-gap: 7px;
row-gap: 0.4375rem;
column-gap: 0;
padding: 9px 0;
padding: 0.5625rem 0;
}
.tl-bar {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
margin: 0 6px;
padding: 0 11px;
gap: 0.0625rem;
margin: 0 0.375rem;
padding: 0 0.6875rem;
height: 100%;
border-radius: 7px;
border-radius: 0.4375rem;
background: var(--bar-weak);
border-left: 3px solid var(--bar-color);
cursor: pointer;
@@ -395,10 +395,10 @@ function onBodyScroll(): void {
}
.tl-bar:hover {
box-shadow: 0 3px 10px rgba(20, 24, 33, 0.13);
transform: translateY(-1px);
transform: translateY(-0.0625rem);
}
.tl-bar .bt {
font-size: 12.5px;
font-size: 0.7813rem;
font-weight: 600;
color: var(--text);
line-height: 1.3;
@@ -413,10 +413,10 @@ function onBodyScroll(): void {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 40px;
gap: 0.625rem;
padding: 2.5rem;
color: var(--text-3);
font-size: 13.5px;
font-size: 0.8438rem;
text-align: center;
}
</style>
@@ -207,39 +207,39 @@ const allOn = computed(
}
.filter-menu {
position: absolute;
top: calc(100% + 7px);
top: calc(100% + 0.4375rem);
right: 0;
z-index: 60;
width: 264px;
width: 16.5rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
border-radius: 0.625rem;
box-shadow: var(--shadow-pop);
padding: 6px;
padding: 0.375rem;
}
.fm-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 8px 9px;
padding: 0.4375rem 0.5rem 0.5625rem;
}
.fm-head > span {
font-size: 11px;
font-size: 0.6875rem;
font-weight: 700;
color: var(--text-3);
letter-spacing: 0.3px;
letter-spacing: 0.0187rem;
white-space: nowrap;
}
.fm-all {
border: none;
background: none;
font-family: inherit;
font-size: 12px;
font-size: 0.75rem;
font-weight: 600;
color: var(--accent);
cursor: pointer;
padding: 2px 5px;
border-radius: 4px;
padding: 0.125rem 0.3125rem;
border-radius: 0.25rem;
white-space: nowrap;
}
.fm-all:hover {
@@ -248,13 +248,13 @@ const allOn = computed(
.fm-list {
display: flex;
flex-direction: column;
max-height: 320px;
max-height: 20rem;
overflow-y: auto;
}
.fm-item {
display: flex;
align-items: center;
border-radius: 6px;
border-radius: 0.375rem;
}
.fm-item:hover {
background: #f4f5f7;
@@ -264,20 +264,20 @@ const allOn = computed(
min-width: 0;
display: flex;
align-items: center;
gap: 9px;
gap: 0.5625rem;
border: none;
background: none;
font-family: inherit;
font-size: 13px;
font-size: 0.8125rem;
font-weight: 600;
padding: 8px;
padding: 0.5rem;
cursor: pointer;
text-align: left;
}
.fm-tog .dot {
width: 10px;
height: 10px;
border-radius: 3px;
width: 0.625rem;
height: 0.625rem;
border-radius: 0.1875rem;
flex-shrink: 0;
}
.fm-tog .fm-label {
@@ -289,21 +289,21 @@ const allOn = computed(
white-space: nowrap;
}
.fm-tog .fm-ct {
font-size: 11px;
font-size: 0.6875rem;
font-weight: 700;
color: var(--text-3);
}
.fm-tog .fm-check {
width: 16px;
height: 16px;
width: 1rem;
height: 1rem;
flex-shrink: 0;
color: var(--accent);
display: grid;
place-items: center;
}
.fm-tog .fm-check svg {
width: 15px;
height: 15px;
width: 0.9375rem;
height: 0.9375rem;
}
.fm-item:not(.on) .fm-label {
color: var(--text-3);
@@ -313,19 +313,19 @@ const allOn = computed(
}
.fm-acts {
display: none;
gap: 1px;
padding-right: 5px;
gap: 0.0625rem;
padding-right: 0.3125rem;
}
.fm-item:hover .fm-acts {
display: flex;
}
.fm-act {
width: 25px;
height: 25px;
width: 1.5625rem;
height: 1.5625rem;
border: none;
background: transparent;
color: var(--text-3);
border-radius: 5px;
border-radius: 0.3125rem;
cursor: pointer;
display: grid;
place-items: center;
@@ -335,30 +335,30 @@ const allOn = computed(
color: var(--text);
}
.fm-act svg {
width: 13px;
height: 13px;
width: 0.8125rem;
height: 0.8125rem;
}
.fm-add {
display: flex;
align-items: center;
gap: 7px;
gap: 0.4375rem;
width: 100%;
border: none;
border-top: 1px solid var(--border);
background: none;
font-family: inherit;
font-size: 13px;
font-size: 0.8125rem;
font-weight: 600;
color: var(--accent);
padding: 10px 8px 6px;
margin-top: 4px;
padding: 0.625rem 0.5rem 0.375rem;
margin-top: 0.25rem;
cursor: pointer;
}
.fm-add:hover {
color: var(--accent-hover);
}
.fm-add svg {
width: 15px;
height: 15px;
width: 0.9375rem;
height: 0.9375rem;
}
</style>
@@ -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>
+19 -39
View File
@@ -8,6 +8,8 @@ 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'
@@ -71,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
@@ -286,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"
@@ -331,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"
@@ -776,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 이 드롭다운을 잘라내므로, 이 화면에서는 보이도록 해제.
+14 -10
View File
@@ -222,7 +222,10 @@ async function onDeleteCategory(id: string): Promise<void> {
align-items: center;
/* 프로젝트 목록 등 전역 .toolbar 와 동일한 컨트롤 간격 */
gap: 0.625rem;
padding: 11px 24px;
/* 가로 여백은 .page(0 1.5rem)·콘텐츠 영역과 동일하게 1.5rem */
padding: 0.6875rem 1.5rem;
/* 전역 .toolbar 의 margin-bottom(0.875rem) 상속 차단 — 고정 바라 여분 마진 불필요 */
margin-bottom: 0;
flex-shrink: 0;
border-bottom: 1px solid var(--border);
background: var(--panel);
@@ -230,14 +233,14 @@ async function onDeleteCategory(id: string): Promise<void> {
.datenav {
display: flex;
align-items: center;
gap: 8px;
gap: 0.5rem;
}
.datenav .range {
font-size: 15.5px;
font-size: 0.969rem;
font-weight: 700;
letter-spacing: -0.3px;
letter-spacing: -0.01875rem;
white-space: nowrap;
margin: 0 6px;
margin: 0 0.375rem;
}
.navgroup {
display: flex;
@@ -250,8 +253,8 @@ async function onDeleteCategory(id: string): Promise<void> {
margin-left: -1px;
}
.navbtn {
width: 32px;
height: 32px;
width: 2rem;
height: 2rem;
border: 1px solid var(--border-strong);
background: #fff;
color: var(--text-2);
@@ -265,8 +268,8 @@ async function onDeleteCategory(id: string): Promise<void> {
z-index: 1;
}
.navbtn svg {
width: 16px;
height: 16px;
width: 1rem;
height: 1rem;
}
.spacer {
flex: 1;
@@ -285,7 +288,8 @@ async function onDeleteCategory(id: string): Promise<void> {
min-width: 0;
overflow: hidden;
display: flex;
padding: 18px 24px 28px;
/* 콘텐츠 영역 상하좌우 여백 통일(1.5rem) — 툴바 가로 여백·.page 와 일치 */
padding: 1.5rem;
}
.detail-backdrop {
position: absolute;
+24 -92
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',
@@ -319,7 +314,13 @@ const CAT_COLOR: Record<ChecklistCategory, string> = {
보안: '#dc2626',
부가: '#b7791f',
}
type CatTab = ChecklistCategory | 'all'
// 보고서 모달 탭 — 카테고리(전체 탭 없음). 기본 탭은 항목이 있는 첫 분류.
type CatTab = ChecklistCategory
function firstCat(items: { category: ChecklistCategory }[]): ChecklistCategory {
return (
CHECKLIST_CATEGORIES.find((c) => items.some((i) => i.category === c)) ?? '필수'
)
}
// KST(UTC+9) 기준 'yyyy-MM-dd' — '당일' 판정
function kstDate(iso: string | Date): string {
@@ -354,12 +355,11 @@ const reportItems = ref<
>([])
const reportNote = ref('')
const reportSubmitting = ref(false)
const writeTab = ref<CatTab>('all')
const writeTab = ref<CatTab>('필수')
function openReportForm(cp: TaskCheckpointView) {
if (!task.value) return
reportingCpId.value = cp.id
writeTab.value = 'all'
// 현재(마지막 보고) 상태로 프리필
reportItems.value = task.value.checklist
.filter((c) => c.id)
@@ -369,16 +369,11 @@ function openReportForm(cp: TaskCheckpointView) {
category: c.category,
workStatus: c.workStatus ?? 'todo',
}))
writeTab.value = firstCat(reportItems.value)
reportNote.value = ''
}
// 작성 모달 — 탭별 그룹/개수/요약
// 작성 모달 — 선택 분류의 그룹/개수/요약
const writeGroups = computed(() => {
if (writeTab.value === 'all') {
return CHECKLIST_CATEGORIES.map((c) => ({
cat: c,
items: reportItems.value.filter((i) => i.category === c),
})).filter((g) => g.items.length)
}
const c = writeTab.value
return [{ cat: c, items: reportItems.value.filter((i) => i.category === c) }]
})
@@ -392,11 +387,7 @@ const writeSummary = computed(() =>
})),
)
// 현재 탭에서 보이는 항목 수(0이면 빈 안내 표시)
const writeVisible = computed(() =>
writeTab.value === 'all'
? reportItems.value.length
: writeCatCount(writeTab.value),
)
const writeVisible = computed(() => writeCatCount(writeTab.value))
function cancelReportForm() {
reportingCpId.value = null
reportItems.value = []
@@ -423,33 +414,24 @@ async function submitReport() {
// --- 보고 기록 열람(모달) — 전체/카테고리 탭 ---
const openedReport = ref<CheckpointReportView | null>(null)
const viewTab = ref<CatTab>('all')
const viewTab = ref<CatTab>('필수')
// 보고 완료 체크포인트 클릭 → 해당 체크포인트의 최신 보고를 모달로 연다
function openReportModal(cp: TaskCheckpointView) {
const r = task.value?.checkpointReports.find((x) => x.checkpointId === cp.id)
if (!r) return
openedReport.value = r
viewTab.value = 'all'
viewTab.value = firstCat(r.items)
}
const viewItems = computed(() => openedReport.value?.items ?? [])
const viewGroups = computed(() => {
const items = viewItems.value
if (viewTab.value === 'all') {
return CHECKLIST_CATEGORIES.map((c) => ({
cat: c,
items: items.filter((i) => i.category === c),
})).filter((g) => g.items.length)
}
const c = viewTab.value
return [{ cat: c, items: items.filter((i) => i.category === c) }]
return [{ cat: c, items: viewItems.value.filter((i) => i.category === c) }]
})
function viewCatCount(cat: ChecklistCategory): number {
return viewItems.value.filter((i) => i.category === cat).length
}
// 현재 탭에서 보이는 항목 수(0이면 빈 안내 표시)
const viewVisible = computed(() =>
viewTab.value === 'all' ? viewItems.value.length : viewCatCount(viewTab.value),
)
const viewVisible = computed(() => viewCatCount(viewTab.value))
const reporterInitial = computed(() => openedReport.value?.reporterName?.[0] ?? '?')
// 담당자 액션 카드 테마(시안): changes→빨강 / review→앰버 / done→초록 / 그 외→accent
const assigneeCardClass = computed(() => {
@@ -1679,14 +1661,6 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
class="pr-tabs"
role="tablist"
>
<button
type="button"
class="pr-tab"
:class="{ on: viewTab === 'all' }"
@click="viewTab = 'all'"
>
전체<span class="pr-tct">{{ viewItems.length }}</span>
</button>
<button
v-for="c in CHECKLIST_CATEGORIES"
:key="c"
@@ -1707,15 +1681,6 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
v-for="g in viewGroups"
:key="g.cat"
>
<div
v-if="viewTab === 'all'"
class="pr-ghead"
>
<span
class="pr-gdot"
:style="{ background: CAT_COLOR[g.cat] }"
/>{{ g.cat }}<span class="pr-gct">· {{ g.items.length }}</span>
</div>
<div
v-for="(it, i) in g.items"
:key="g.cat + '-' + i"
@@ -1738,7 +1703,7 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
v-if="viewVisible === 0"
class="pr-empty"
>
{{ viewTab === 'all' ? '보고된 항목이 없습니다.' : `'${viewTab}' 분류에 항목이 없습니다.` }}
'{{ viewTab }}' 분류에 항목이 없습니다.
</div>
</div>
@@ -1815,14 +1780,6 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
class="pr-tabs"
role="tablist"
>
<button
type="button"
class="pr-tab"
:class="{ on: writeTab === 'all' }"
@click="writeTab = 'all'"
>
전체<span class="pr-tct">{{ reportItems.length }}</span>
</button>
<button
v-for="c in CHECKLIST_CATEGORIES"
:key="c"
@@ -1843,15 +1800,6 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
v-for="g in writeGroups"
:key="g.cat"
>
<div
v-if="writeTab === 'all'"
class="pr-ghead"
>
<span
class="pr-gdot"
:style="{ background: CAT_COLOR[g.cat] }"
/>{{ g.cat }}<span class="pr-gct">· {{ g.items.length }}</span>
</div>
<div
v-for="it in g.items"
:key="it.id"
@@ -1866,7 +1814,7 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
v-if="writeVisible === 0"
class="pr-empty"
>
{{ writeTab === 'all' ? '보고할 할 일이 없습니다. 메모만 전송됩니다.' : `'${writeTab}' 분류에 할 일이 없습니다.` }}
'{{ writeTab }}' 분류에 일이 없습니다.
</div>
</div>
@@ -3812,24 +3760,6 @@ a.file-info {
padding: 0 1.5rem 0.5rem;
border-top: 1px solid var(--border);
}
.pr-ghead {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 0.125rem 0.4375rem;
font-size: 0.719rem;
font-weight: 700;
color: var(--text-3);
}
.pr-ghead .pr-gdot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.pr-ghead .pr-gct {
color: var(--text-3);
font-weight: 600;
}
.pr-task,
.pr-titem {
display: flex;
@@ -3952,6 +3882,8 @@ a.file-info {
gap: 0.75rem;
padding: 0.875rem 1.5rem;
border-top: 1px solid var(--border);
/* 다른 모달 푸터(BaseModal .bm-foot)와 동일한 배경 */
background: #fafbfc;
}
.pr-foot .pr-sp {
flex: 1;
@@ -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: '완료',