feat: 업무 상세 부속·활동 피드·내 업무 집계 + 페이지네이션 (5~8단계)
- 5단계(업무 상세 부속): 체크리스트(상세 읽기전용/수정폼 추가·삭제)·댓글/답글·파일 첨부(서버 로컬 업로드 폴더)·승인 워크플로우(요청/승인/수정요청 노트) - 6단계(활동 피드): 저장소/멤버/업무 행위를 이벤트로 자동 적재, 저장소 활동 피드·업무 활동 탭 실연동(프론트가 type+payload로 문구 조립) - 7단계(내 업무): 담당/지시 저장소 횡단 집계, MyTasksPage 실연동, TaskAssigneePage(/view) 제거 - 8단계(진행): 목록 페이지네이션 통일 — 백엔드 전체(공통 유틸 + 5개 목록, 저장소 업무는 세그먼트/검색/카운트) + 프론트 더보기(저장소 목록·내 업무 완료, 멤버·저장소 업무 대기) - UI 보강: 업무 목록 댓글/첨부 분리표기, 첨부 배지 색상·라벨 통일, 완료 업무 D-Day 배지, 활동 이벤트 점검·보강(업무 수정/삭제·답글·첨부 삭제) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,34 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
// 내 업무 — 담당/지시 두 뷰를 탭으로 전환, 상태 그룹별 업무 목록
|
||||
import { computed, ref } from 'vue'
|
||||
// 내 업무 — 담당/지시 두 뷰를 탭으로 전환, 상태 그룹별 업무 목록 (API 연동)
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { MY_TASKS_ASSIGNED, MY_TASKS_ISSUED, countActive, type MyTaskRow } from '@/mock/relay.mock'
|
||||
import { useMyTaskStore } from '@/stores/my-task.store'
|
||||
import { countActive, type MyTaskRow } from '@/mock/relay.mock'
|
||||
|
||||
type View = 'assigned' | 'issued'
|
||||
const view = ref<View>('assigned')
|
||||
|
||||
const REPO = 'q3-launch-campaign'
|
||||
const myTaskStore = useMyTaskStore()
|
||||
onMounted(() => {
|
||||
void myTaskStore.fetchAll()
|
||||
})
|
||||
|
||||
// 행 클릭 시 이동 경로
|
||||
// - 담당 뷰: 담당자 관점 업무 상세(상태별 분기)
|
||||
// - 지시 뷰: 지시자 관점 업무 상세(TaskDetailPage)
|
||||
// 행 클릭 → 항상 업무 상세(TaskDetailPage). 역할 기반 액션은 상세에서 분기한다
|
||||
function taskLink(row: MyTaskRow): string {
|
||||
if (view.value === 'issued') return `/repos/${REPO}/tasks/${row.id}`
|
||||
const state = row.status === 'changes' ? 'changes' : row.status === 'done' ? 'approved' : 'request'
|
||||
return `/repos/${REPO}/tasks/${row.id}/view?state=${state}`
|
||||
return `/repos/${row.repoId}/tasks/${row.id}`
|
||||
}
|
||||
|
||||
const groups = computed(() => (view.value === 'assigned' ? MY_TASKS_ASSIGNED : MY_TASKS_ISSUED))
|
||||
const groups = computed(() => (view.value === 'assigned' ? myTaskStore.assigned : myTaskStore.issued))
|
||||
|
||||
const assignedCount = countActive(MY_TASKS_ASSIGNED)
|
||||
const issuedCount = countActive(MY_TASKS_ISSUED)
|
||||
const assignedCount = computed(() => countActive(myTaskStore.assigned))
|
||||
const issuedCount = computed(() => countActive(myTaskStore.issued))
|
||||
// 지시 뷰의 승인 대기(내 승인 필요) 건수
|
||||
const issuedReviewCount = computed(
|
||||
() => MY_TASKS_ISSUED.find((g) => g.key === 'review')?.rows.length ?? 0,
|
||||
() => myTaskStore.issued.find((g) => g.key === 'review')?.rows.length ?? 0,
|
||||
)
|
||||
// 승인 대기 강조 배너는 지시 뷰에서 표시
|
||||
const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCount.value > 0)
|
||||
|
||||
const loading = computed(() => myTaskStore.loading)
|
||||
const isEmpty = computed(() => !loading.value && groups.value.length === 0)
|
||||
|
||||
// 더보기 — 현재 뷰(담당/지시) 기준
|
||||
const hasMore = computed(() =>
|
||||
view.value === 'assigned' ? myTaskStore.assignedHasMore : myTaskStore.issuedHasMore,
|
||||
)
|
||||
const loadingMore = computed(() => myTaskStore.loadingMore)
|
||||
function loadMore() {
|
||||
if (view.value === 'assigned') void myTaskStore.loadMoreAssigned()
|
||||
else void myTaskStore.loadMoreIssued()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -37,22 +50,6 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo
|
||||
<div class="pagehead">
|
||||
<h1>내 업무</h1>
|
||||
<span class="sub">나에게 배정되었거나 내가 지시한 업무를 한곳에서 관리하세요.</span>
|
||||
<RouterLink
|
||||
class="new-btn"
|
||||
to="/repos/q3-launch-campaign/tasks/new"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 12h14M12 5v14" />
|
||||
</svg>
|
||||
새 업무 지시
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- 담당 / 지시 토글 -->
|
||||
@@ -162,6 +159,20 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo
|
||||
<span><b>{{ issuedReviewCount }}건</b>의 업무가 내 승인을 기다리고 있어요. 검토 후 승인하거나 수정을 요청하세요.</span>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 / 빈 상태 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="state-msg"
|
||||
>
|
||||
내 업무를 불러오는 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isEmpty"
|
||||
class="state-msg"
|
||||
>
|
||||
{{ view === 'assigned' ? '담당 중인 업무가 없습니다.' : '지시한 업무가 없습니다.' }}
|
||||
</div>
|
||||
|
||||
<!-- 상태 그룹 -->
|
||||
<div
|
||||
v-for="group in groups"
|
||||
@@ -332,6 +343,17 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 더보기 -->
|
||||
<button
|
||||
v-if="hasMore"
|
||||
class="load-more"
|
||||
type="button"
|
||||
:disabled="loadingMore"
|
||||
@click="loadMore"
|
||||
>
|
||||
{{ loadingMore ? '불러오는 중…' : '더보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -355,31 +377,6 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo
|
||||
padding-bottom: 0.125rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.new-btn {
|
||||
margin-left: auto;
|
||||
height: 2.25rem;
|
||||
padding: 0 0.9375rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.844rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.new-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.new-btn svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
|
||||
/* 담당/지시 토글 */
|
||||
.viewtabs {
|
||||
@@ -396,6 +393,7 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo
|
||||
margin-right: 1.125rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: var(--text-2);
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -405,6 +403,9 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
.viewtab .tb-label {
|
||||
line-height: 1;
|
||||
}
|
||||
.viewtab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -489,6 +490,18 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 로딩/빈 상태 */
|
||||
.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 {
|
||||
margin-bottom: 1.375rem;
|
||||
|
||||
@@ -1,29 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
// 저장소 활동 — 날짜 그룹 단위 활동 피드
|
||||
import { computed, defineComponent, h, ref } from 'vue'
|
||||
// 저장소 활동 — 날짜 그룹 단위 활동 피드 (API 연동)
|
||||
import { computed, defineComponent, h, ref, watch } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import RepoHeader from '@/components/RepoHeader.vue'
|
||||
import RepoTabs from '@/components/RepoTabs.vue'
|
||||
import { REPO_ACTIVITY, findRepo, findRepoMembers, type ActivityIcon } from '@/mock/relay.mock'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { useActivityStore } from '@/stores/activity.store'
|
||||
import type { Repo, ActivityIcon } from '@/mock/relay.mock'
|
||||
|
||||
type Filter = 'all' | 'task' | 'member' | 'setting'
|
||||
|
||||
const route = useRoute()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repo = computed(() => findRepo(repoId.value))
|
||||
const headerMembers = computed(() => findRepoMembers(repoId.value).map((m) => m.user))
|
||||
const repoStore = useRepoStore()
|
||||
const activityStore = useActivityStore()
|
||||
|
||||
// 저장소 헤더 + 활동 피드 로딩
|
||||
const repo = ref<Repo | null>(null)
|
||||
async function load() {
|
||||
try {
|
||||
repo.value = await repoStore.fetchOne(repoId.value)
|
||||
} catch {
|
||||
repo.value = null // 404/403 등은 인터셉터가 알림 처리
|
||||
}
|
||||
try {
|
||||
await activityStore.fetch(repoId.value)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
watch(repoId, load, { immediate: true })
|
||||
|
||||
const filter = ref<Filter>('all')
|
||||
|
||||
// 필터 적용 후 빈 날짜 그룹은 제거
|
||||
const days = computed(() =>
|
||||
REPO_ACTIVITY.map((d) => ({
|
||||
day: d.day,
|
||||
items: d.items.filter((it) => filter.value === 'all' || it.tone === filter.value),
|
||||
})).filter((d) => d.items.length > 0),
|
||||
activityStore.days
|
||||
.map((d) => ({
|
||||
day: d.day,
|
||||
items: d.items.filter((it) => filter.value === 'all' || it.tone === filter.value),
|
||||
}))
|
||||
.filter((d) => d.items.length > 0),
|
||||
)
|
||||
|
||||
const memberCount = computed(() =>
|
||||
repo.value ? repo.value.members.length + repo.value.moreCount : 0,
|
||||
)
|
||||
const loading = computed(() => activityStore.loading)
|
||||
const isEmpty = computed(() => !loading.value && days.value.length === 0)
|
||||
|
||||
// 활동 아이콘 — name 으로 SVG path 분기
|
||||
const ACT_PATHS: Record<ActivityIcon, string> = {
|
||||
check: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
||||
@@ -77,15 +103,13 @@ const ActIcon = defineComponent({
|
||||
|
||||
<RepoHeader
|
||||
:repo="repo"
|
||||
meta-text="2시간 전 업데이트"
|
||||
:members="headerMembers"
|
||||
:more-count="0"
|
||||
:meta-text="repo.updatedAgo"
|
||||
/>
|
||||
<RepoTabs
|
||||
:repo-id="repo.id"
|
||||
active="activity"
|
||||
:task-count="12"
|
||||
:member-count="headerMembers.length"
|
||||
:task-count="repo.totalCount"
|
||||
:member-count="memberCount"
|
||||
/>
|
||||
|
||||
<!-- 툴바 -->
|
||||
@@ -145,10 +169,27 @@ const ActIcon = defineComponent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 / 빈 상태 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="feed-state"
|
||||
>
|
||||
활동을 불러오는 중입니다…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isEmpty"
|
||||
class="feed-state"
|
||||
>
|
||||
아직 기록된 활동이 없습니다.
|
||||
</div>
|
||||
|
||||
<!-- 활동 피드 -->
|
||||
<!-- act-text 의 v-html 은 내부에서 생성한 신뢰 가능한 활동 마크업(목업)만 렌더한다 (XSS 위험 없음) -->
|
||||
<!-- act-text 의 v-html 은 store 에서 사용자 입력을 escapeHtml 로 이스케이프한 뒤 신뢰 태그만 조립한 마크업이다 (XSS 위험 없음) -->
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div class="feed">
|
||||
<div
|
||||
v-else
|
||||
class="feed"
|
||||
>
|
||||
<div class="feed-inner">
|
||||
<template
|
||||
v-for="group in days"
|
||||
@@ -351,4 +392,16 @@ const ActIcon = defineComponent({
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
/* 로딩/빈 상태 */
|
||||
.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>
|
||||
|
||||
@@ -334,10 +334,26 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
{{ task.comments }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.files"
|
||||
class="mi"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
{{ task.files }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.status === 'done'"
|
||||
class="mi"
|
||||
|
||||
@@ -13,7 +13,7 @@ const keyword = ref('')
|
||||
const filter = ref<Filter>('all')
|
||||
|
||||
const repoStore = useRepoStore()
|
||||
const { repos, loading, owner } = storeToRefs(repoStore)
|
||||
const { repos, loading, owner, total, hasMore, loadingMore } = storeToRefs(repoStore)
|
||||
|
||||
onMounted(() => {
|
||||
void repoStore.fetchList()
|
||||
@@ -45,7 +45,7 @@ const filteredRepos = computed(() =>
|
||||
|
||||
<div class="pagehead">
|
||||
<h1>저장소</h1>
|
||||
<span class="count-pill">{{ repos.length }}</span>
|
||||
<span class="count-pill">{{ total }}</span>
|
||||
<span
|
||||
v-if="giteaConnected"
|
||||
class="gitea-badge"
|
||||
@@ -316,6 +316,17 @@ const filteredRepos = computed(() =>
|
||||
{{ repos.length === 0 ? '아직 저장소가 없습니다. 새 저장소를 만들어 보세요.' : '검색 결과가 없습니다.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 더보기 -->
|
||||
<button
|
||||
v-if="hasMore"
|
||||
class="load-more"
|
||||
type="button"
|
||||
:disabled="loadingMore"
|
||||
@click="repoStore.loadMore()"
|
||||
>
|
||||
{{ loadingMore ? '불러오는 중…' : '더보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -73,7 +73,8 @@ async function loadTaskForEdit() {
|
||||
contentText.value = t.content.join('\n')
|
||||
dueDate.value = t.dueDate ? toDateInput(t.dueDate) : ''
|
||||
assignees.value = t.assignees.map(toUserView)
|
||||
checklist.value = t.checklist.map((c) => ({ text: c.text, done: c.done }))
|
||||
// id 를 보존해 저장 시 기존 항목을 식별(완료 상태 유지)
|
||||
checklist.value = t.checklist.map((c) => ({ id: c.id, text: c.text, done: c.done }))
|
||||
} catch {
|
||||
// 404 등은 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -170,12 +171,15 @@ async function submitTask() {
|
||||
const dueIso = dueDate.value ? new Date(dueDate.value).toISOString() : null
|
||||
|
||||
if (isEdit.value && taskId.value !== null) {
|
||||
// 수정 — 제목/내용/담당자/마감만 갱신(체크리스트 항목 편집은 5단계)
|
||||
// 수정 — 제목/내용/담당자/마감 + 체크리스트(추가/삭제/순서) 동기화
|
||||
const updated = await taskStore.update(repoId.value, taskId.value, {
|
||||
title: title.value.trim(),
|
||||
content,
|
||||
assigneeIds,
|
||||
dueDate: dueIso,
|
||||
checklist: checklist.value.map((c) =>
|
||||
c.id ? { id: c.id, text: c.text } : { text: c.text },
|
||||
),
|
||||
})
|
||||
router.push(`/repos/${repoId.value}/tasks/${updated.id}`)
|
||||
} else {
|
||||
@@ -399,7 +403,6 @@ function goBack() {
|
||||
</span>
|
||||
<span class="txt">{{ item.text }}</span>
|
||||
<span
|
||||
v-if="!isEdit"
|
||||
class="row-del"
|
||||
@click="removeItem(index)"
|
||||
>
|
||||
@@ -414,10 +417,7 @@ function goBack() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isEdit"
|
||||
class="add-row"
|
||||
>
|
||||
<div class="add-row">
|
||||
<span class="plus">+</span>
|
||||
<input
|
||||
v-model="newItem"
|
||||
@@ -426,12 +426,6 @@ function goBack() {
|
||||
@keydown.enter="addItem"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="checklist-note"
|
||||
>
|
||||
체크리스트 항목 편집은 추후 단계에서 지원됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -846,11 +840,6 @@ function goBack() {
|
||||
.add-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.checklist-note {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 사이드바 */
|
||||
.side {
|
||||
|
||||
@@ -17,14 +17,13 @@ import AppShell from '@/layouts/AppShell.vue'
|
||||
import {
|
||||
type Attachment,
|
||||
type Comment,
|
||||
type Reply,
|
||||
type TaskDetail,
|
||||
type TaskStatus,
|
||||
type User,
|
||||
} from '@/mock/relay.mock'
|
||||
import { useTaskStore } from '@/stores/task.store'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { avatarColor, initialOf } from '@/shared/utils/format'
|
||||
import { attachmentKindOf, avatarColor, fileBadge, initialOf } from '@/shared/utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -115,29 +114,34 @@ async function confirmDelete() {
|
||||
}
|
||||
}
|
||||
|
||||
// 승인(review→done) / 수정요청(review→changes) — 지시자 액션
|
||||
// 승인(review→done) — 지시자 액션. 승인 워크플로우 엔드포인트 사용
|
||||
async function approveTask() {
|
||||
if (!task.value) return
|
||||
try {
|
||||
task.value = await taskStore.changeStatus(repoId.value, task.value.id, 'done')
|
||||
task.value = await taskStore.approve(repoId.value, task.value.id)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
showApprove.value = false
|
||||
}
|
||||
}
|
||||
async function requestChanges() {
|
||||
// 수정 요청(review→changes) — 지시자 액션. 보완 메시지 필수
|
||||
const changesNote = ref('')
|
||||
async function submitChanges() {
|
||||
if (!task.value) return
|
||||
const note = changesNote.value.trim()
|
||||
if (!note) return
|
||||
try {
|
||||
task.value = await taskStore.changeStatus(repoId.value, task.value.id, 'changes')
|
||||
task.value = await taskStore.requestChanges(repoId.value, task.value.id, note)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
showChanges.value = false
|
||||
changesNote.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 담당자 액션 — 업무 시작(todo→prog) / 승인 요청(prog→review) / 다시 승인 요청(changes→review)
|
||||
// 담당자 액션 — 업무 시작(todo→prog) / 승인 요청(prog/changes→review)
|
||||
async function changeTo(status: TaskStatus) {
|
||||
if (!task.value) return
|
||||
try {
|
||||
@@ -149,8 +153,21 @@ async function changeTo(status: TaskStatus) {
|
||||
function startTask() {
|
||||
void changeTo('prog')
|
||||
}
|
||||
function requestApproval() {
|
||||
void changeTo('review')
|
||||
// 승인 요청(메시지 선택) — 승인 워크플로우 엔드포인트로 노트와 함께 전송
|
||||
const approvalNote = ref('')
|
||||
async function sendApproval() {
|
||||
if (!task.value) return
|
||||
try {
|
||||
task.value = await taskStore.requestApproval(
|
||||
repoId.value,
|
||||
task.value.id,
|
||||
approvalNote.value.trim() || undefined,
|
||||
)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
approvalNote.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -158,8 +175,8 @@ function requestApproval() {
|
||||
* ============================================================ */
|
||||
const activeTab = ref<'comments' | 'activity'>('comments')
|
||||
|
||||
// 댓글 — 5단계 실연동 예정. 현재는 로컬 상태 데모(새로고침 시 비어 있음)
|
||||
const comments = reactive<Comment[]>([])
|
||||
// 댓글 — 상세(API)에서 받아온 목록을 사용. 작성/답글은 store 를 통해 영속
|
||||
const comments = computed<Comment[]>(() => task.value?.comments ?? [])
|
||||
const replyOpen = ref<number | null>(null)
|
||||
const replyText = ref('')
|
||||
const commentText = ref('')
|
||||
@@ -201,26 +218,68 @@ function toggleReply(index: number) {
|
||||
replyOpen.value = replyOpen.value === index ? null : index
|
||||
replyText.value = ''
|
||||
}
|
||||
function submitReply(index: number) {
|
||||
async function submitReply(index: number) {
|
||||
const text = replyText.value.trim()
|
||||
const target = comments[index]
|
||||
if (!text || !target) return
|
||||
const reply: Reply = { author: me.value, time: '방금', text }
|
||||
target.replies.push(reply)
|
||||
const target = comments.value[index]
|
||||
if (!text || !target?.id || !task.value) return
|
||||
try {
|
||||
task.value = await taskStore.addReply(repoId.value, task.value.id, target.id, text)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
replyText.value = ''
|
||||
replyOpen.value = null
|
||||
}
|
||||
function submitComment() {
|
||||
async function submitComment() {
|
||||
const text = commentText.value.trim()
|
||||
if (!text) return
|
||||
comments.push({ author: me.value, roleBadge: '지시자', time: '방금', text, replies: [] })
|
||||
if (!text || !task.value) return
|
||||
try {
|
||||
task.value = await taskStore.addComment(repoId.value, task.value.id, text)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
commentText.value = ''
|
||||
}
|
||||
|
||||
const commentTotal = computed(
|
||||
() => comments.length + comments.reduce((sum, c) => sum + c.replies.length, 0),
|
||||
() => comments.value.length + comments.value.reduce((sum, c) => sum + c.replies.length, 0),
|
||||
)
|
||||
|
||||
/* ============================================================
|
||||
* 파일 첨부(5단계) — 업로드/삭제
|
||||
* ============================================================ */
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const uploading = ref(false)
|
||||
|
||||
function pickFile() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
async function onFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file || !task.value) {
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
task.value = await taskStore.uploadFile(repoId.value, task.value.id, file)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
uploading.value = false
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
async function removeFile(att: Attachment) {
|
||||
if (!task.value || !att.id) return
|
||||
try {
|
||||
task.value = await taskStore.removeFile(repoId.value, task.value.id, att.id)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 키보드 / 외부 클릭 처리
|
||||
* ============================================================ */
|
||||
@@ -435,12 +494,9 @@ function quickChip(cmd: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 아이콘 클래스/라벨 (유한 키 Record — 인덱스 접근이 undefined 로 넓어지지 않음)
|
||||
const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
img: { cls: 'fi-img', label: 'IMG' },
|
||||
zip: { cls: 'fi-zip', label: 'ZIP' },
|
||||
pdf: { cls: 'fi-img', label: 'PDF' },
|
||||
doc: { cls: 'fi-img', label: 'DOC' },
|
||||
// 파일명(확장자) → 종류 배지(라벨/색상). 활동·댓글은 파일명만 있으므로 종류를 파생
|
||||
function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
return fileBadge(attachmentKindOf(name ?? ''))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -653,10 +709,14 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
<span class="progress-txt">{{ checklistDone }} / {{ task.checklist.length }} 완료</span>
|
||||
</span>
|
||||
</div>
|
||||
<!--
|
||||
체크리스트는 읽기 전용 — 사용자가 개별 토글/추가/삭제하지 않는다.
|
||||
완료 처리는 승인 워크플로우(승인 완료 시 일괄 완료)로만 이루어진다.
|
||||
-->
|
||||
<div class="checklist">
|
||||
<div
|
||||
v-for="(item, i) in task.checklist"
|
||||
:key="i"
|
||||
:key="item.id ?? i"
|
||||
class="citem"
|
||||
:class="{ done: item.done }"
|
||||
>
|
||||
@@ -729,7 +789,10 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
v-if="c.file"
|
||||
class="c-file"
|
||||
>
|
||||
<span class="fi">IMG</span>{{ c.file }}
|
||||
<span
|
||||
class="fi"
|
||||
:class="badgeFor(c.file).cls"
|
||||
>{{ badgeFor(c.file).label }}</span>{{ c.file }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -780,8 +843,8 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
>
|
||||
<span
|
||||
class="c-av"
|
||||
style="background: #0ea5a3"
|
||||
>정</span>
|
||||
:class="me.color"
|
||||
>{{ me.initial }}</span>
|
||||
<div class="field">
|
||||
<textarea
|
||||
v-model="replyText"
|
||||
@@ -858,7 +921,10 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
v-if="a.file"
|
||||
class="act-file"
|
||||
>
|
||||
<span class="fi">IMG</span>{{ a.file }}
|
||||
<span
|
||||
class="fi"
|
||||
:class="badgeFor(a.file).cls"
|
||||
>{{ badgeFor(a.file).label }}</span>{{ a.file }}
|
||||
</div>
|
||||
<div
|
||||
v-if="a.statusFrom"
|
||||
@@ -910,7 +976,18 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
<span class="ap-card-title">승인 요청 도착</span>
|
||||
</div>
|
||||
<div class="ap-card-meta">
|
||||
담당자가 작업 완료 후 승인을 요청했습니다. 검토 후 승인하거나 수정을 요청하세요.
|
||||
<template v-if="task.approval">
|
||||
<b>{{ task.approval.requester }}</b>님이 {{ task.approval.requestedAgo }} 승인을 요청했습니다. 검토 후 승인하거나 수정을 요청하세요.
|
||||
</template>
|
||||
<template v-else>
|
||||
담당자가 작업 완료 후 승인을 요청했습니다. 검토 후 승인하거나 수정을 요청하세요.
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="task.approval && task.approval.note"
|
||||
class="ap-note-box"
|
||||
>
|
||||
{{ task.approval.note }}
|
||||
</div>
|
||||
<div class="ap-card-actions">
|
||||
<button
|
||||
@@ -1006,11 +1083,16 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
<div class="ap-card-desc">
|
||||
작업을 마쳤다면 지시자 <b>{{ task.issuer.name }}</b>님에게 검토 승인을 요청하세요. 승인되면 업무가 완료 처리됩니다.
|
||||
</div>
|
||||
<textarea
|
||||
v-model="approvalNote"
|
||||
class="ap-note"
|
||||
placeholder="승인 요청 메시지(선택) — 작업 내용이나 검토 포인트를 적어주세요."
|
||||
/>
|
||||
<div class="ap-card-actions">
|
||||
<button
|
||||
class="ap-btn request-send"
|
||||
type="button"
|
||||
@click="requestApproval"
|
||||
@click="sendApproval"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -1042,11 +1124,22 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
<div class="ap-card-meta">
|
||||
지시자 <b>{{ task.issuer.name }}</b>님이 수정을 요청했습니다. 보완 후 다시 승인을 요청하세요.
|
||||
</div>
|
||||
<div
|
||||
v-if="task.changesNote"
|
||||
class="ap-note-box"
|
||||
>
|
||||
{{ task.changesNote }}
|
||||
</div>
|
||||
<textarea
|
||||
v-model="approvalNote"
|
||||
class="ap-note"
|
||||
placeholder="보완 내용 메시지(선택)"
|
||||
/>
|
||||
<div class="ap-card-actions">
|
||||
<button
|
||||
class="ap-btn request-send"
|
||||
type="button"
|
||||
@click="requestApproval"
|
||||
@click="sendApproval"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -1151,33 +1244,88 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
height="18"
|
||||
rx="2"
|
||||
/><path d="M16 2v4M8 2v4M3 10h18" /></svg>
|
||||
{{ task.dueLabel }}
|
||||
<span class="dday">{{ task.dday }}</span>
|
||||
{{ task.dueLabel || '마감 없음' }}
|
||||
<span
|
||||
v-if="task.dday"
|
||||
class="dday"
|
||||
>{{ task.dday }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="task.files.length"
|
||||
class="card side-card"
|
||||
>
|
||||
<div class="card side-card">
|
||||
<div class="side-field">
|
||||
<div class="side-label">
|
||||
첨부파일 · {{ task.files.length }}개
|
||||
<div class="side-label files-head">
|
||||
<span>첨부파일 · {{ task.files.length }}개</span>
|
||||
<button
|
||||
class="file-add"
|
||||
type="button"
|
||||
:disabled="uploading"
|
||||
@click="pickFile"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M12 5v14M5 12h14" /></svg>
|
||||
{{ uploading ? '업로드 중…' : '추가' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="files">
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="file-hidden"
|
||||
type="file"
|
||||
@change="onFileChange"
|
||||
>
|
||||
<div
|
||||
v-if="task.files.length"
|
||||
class="files"
|
||||
>
|
||||
<div
|
||||
v-for="f in task.files"
|
||||
:key="f.name"
|
||||
:key="f.id ?? f.name"
|
||||
class="file"
|
||||
>
|
||||
<span
|
||||
<a
|
||||
class="file-ico"
|
||||
:class="FILE_META[f.kind].cls"
|
||||
>{{ FILE_META[f.kind].label }}</span>
|
||||
<span class="file-info"><span class="file-name">{{ f.name }}</span><span class="file-size">{{ f.size }}</span></span>
|
||||
:class="fileBadge(f.kind).cls"
|
||||
:href="f.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ fileBadge(f.kind).label }}</a>
|
||||
<a
|
||||
class="file-info"
|
||||
:href="f.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
><span class="file-name">{{ f.name }}</span><span class="file-size">{{ f.size }}</span></a>
|
||||
<button
|
||||
class="file-del"
|
||||
type="button"
|
||||
title="첨부 삭제"
|
||||
aria-label="첨부 삭제"
|
||||
@click="removeFile(f)"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="files-empty"
|
||||
>
|
||||
아직 첨부된 파일이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1329,6 +1477,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
보완이 필요한 내용을 적어주세요. 담당자에게 전달되며 업무가 다시 <b>진행 중</b>으로 돌아갑니다.
|
||||
</p>
|
||||
<textarea
|
||||
v-model="changesNote"
|
||||
class="modal-textarea"
|
||||
placeholder="예) 세로형 카피 가독성이 낮으니 자간과 대비를 조정해주세요."
|
||||
/>
|
||||
@@ -1344,7 +1493,8 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
<button
|
||||
class="mbtn danger"
|
||||
type="button"
|
||||
@click="requestChanges"
|
||||
:disabled="!changesNote.trim()"
|
||||
@click="submitChanges"
|
||||
>
|
||||
수정 요청 보내기
|
||||
</button>
|
||||
@@ -2041,7 +2191,6 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 0.3125rem;
|
||||
background: #e25950;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -2254,7 +2403,6 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 4px;
|
||||
background: #e25950;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -2445,12 +2593,19 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
}
|
||||
/* 파일 종류별 배지 색상 — 첨부 카드(.file-ico)·댓글(.c-file .fi)·활동(.act-file .fi) 공통 */
|
||||
.fi-img {
|
||||
background: #2aa775;
|
||||
}
|
||||
.fi-pdf {
|
||||
background: #e25950;
|
||||
}
|
||||
.fi-zip {
|
||||
background: #8a64d6;
|
||||
}
|
||||
.fi-doc {
|
||||
background: #3d8bf2;
|
||||
}
|
||||
.file-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -2646,6 +2801,114 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
.ap-btn.request-send:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* ===== 5단계: 첨부파일 ===== */
|
||||
.files-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.file-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.719rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-add:hover {
|
||||
background: #f5f6f8;
|
||||
}
|
||||
.file-add:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.file-add svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
.file-hidden {
|
||||
display: none;
|
||||
}
|
||||
a.file-ico,
|
||||
a.file-info {
|
||||
text-decoration: none;
|
||||
}
|
||||
/* 배지(.file-ico)는 흰 글자 유지, 파일명(.file-info)만 본문색 상속 */
|
||||
a.file-ico {
|
||||
color: #fff;
|
||||
}
|
||||
a.file-info {
|
||||
color: inherit;
|
||||
}
|
||||
.file-del {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.file:hover .file-del {
|
||||
opacity: 1;
|
||||
}
|
||||
.file-del:hover {
|
||||
background: var(--red-weak);
|
||||
color: var(--red);
|
||||
}
|
||||
.file-del svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
.files-empty {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* ===== 5단계: 승인/수정 요청 메모 ===== */
|
||||
.ap-note {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 3.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-size: 0.781rem;
|
||||
color: var(--text);
|
||||
padding: 0.5rem 0.625rem;
|
||||
margin-bottom: 0.625rem;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
}
|
||||
.ap-note:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.ap-note::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.ap-note-box {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-2);
|
||||
line-height: 1.55;
|
||||
background: rgba(20, 24, 33, 0.04);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5625rem 0.6875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 모달/에이전트 패널은 Teleport 로 body 에 렌더되므로 전역(scoped 미적용) 스타일 사용 -->
|
||||
|
||||
Reference in New Issue
Block a user