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:
2026-06-18 18:13:17 +09:00
parent 875eddf659
commit a056d7f55a
51 changed files with 3437 additions and 1732 deletions
+25
View File
@@ -596,3 +596,28 @@ body {
.modal-textarea::placeholder {
color: var(--text-3);
}
/* ============================================================
* 더보기 버튼 (목록 페이지네이션 공통)
* ============================================================ */
.load-more {
display: block;
margin: 1.25rem auto 0;
padding: 0.5rem 1.375rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: #fff;
font-family: inherit;
font-size: 0.844rem;
font-weight: 600;
color: var(--text-2);
cursor: pointer;
}
.load-more:hover {
background: #f7f8fa;
color: var(--text);
}
.load-more:disabled {
opacity: 0.6;
cursor: default;
}
+14
View File
@@ -0,0 +1,14 @@
import { useApi } from './useApi'
import type { ApiActivity } from '@/types/activity'
// 활동 피드 API Composable — 인터셉터가 success/data 를 언래핑
export function useActivity() {
const api = useApi()
// 저장소 활동 피드(최신순)
async function list(repoId: string): Promise<ApiActivity[]> {
return (await api.get(`/repos/${repoId}/activity`)) as unknown as ApiActivity[]
}
return { list }
}
+24
View File
@@ -0,0 +1,24 @@
import { useApi } from './useApi'
import type { ApiMyTaskRow } from '@/types/my-task'
import type { Paginated } from '@/types/pagination'
// 내 업무 API Composable — 저장소 횡단 집계(담당/지시), 오프셋 페이지네이션
export function useMyTask() {
const api = useApi()
// 내가 담당인 업무
async function assigned(page = 1, size = 20): Promise<Paginated<ApiMyTaskRow>> {
return (await api.get('/tasks/assigned', {
params: { page, size },
})) as unknown as Paginated<ApiMyTaskRow>
}
// 내가 지시한 업무
async function issued(page = 1, size = 20): Promise<Paginated<ApiMyTaskRow>> {
return (await api.get('/tasks/issued', {
params: { page, size },
})) as unknown as Paginated<ApiMyTaskRow>
}
return { assigned, issued }
}
+5 -2
View File
@@ -5,13 +5,16 @@ import type {
RepoCreateMeta,
UpdateRepoPayload,
} from '@/types/repo'
import type { Paginated } from '@/types/pagination'
// 저장소 도메인 API Composable — 인터셉터가 success/data 를 언래핑
export function useRepo() {
const api = useApi()
async function list(): Promise<ApiRepo[]> {
return (await api.get('/repos')) as unknown as ApiRepo[]
async function list(page = 1, size = 20): Promise<Paginated<ApiRepo>> {
return (await api.get('/repos', {
params: { page, size },
})) as unknown as Paginated<ApiRepo>
}
// 저장소 생성 폼 메타(소유자/템플릿) — 생성 전 미리 조회
+97 -1
View File
@@ -1,5 +1,8 @@
import { useApi } from './useApi'
import type {
ApiAttachment,
ApiComment,
ApiCommentReply,
ApiRepoTask,
ApiTaskDetail,
CreateTaskPayload,
@@ -58,5 +61,98 @@ export function useTask() {
})) as unknown as ApiTaskDetail
}
return { list, get, create, update, remove, changeStatus }
/* ===================== 댓글 / 답글 (5단계) ===================== */
async function addComment(
repoId: string,
taskId: number,
text: string,
fileId?: string,
): Promise<ApiComment> {
return (await api.post(`/repos/${repoId}/tasks/${taskId}/comments`, {
text,
...(fileId ? { fileId } : {}),
})) as unknown as ApiComment
}
async function addReply(
repoId: string,
taskId: number,
commentId: string,
text: string,
): Promise<ApiCommentReply> {
return (await api.post(
`/repos/${repoId}/tasks/${taskId}/comments/${commentId}/replies`,
{ text },
)) as unknown as ApiCommentReply
}
/* ===================== 파일 첨부 (5단계) ===================== */
async function uploadFile(
repoId: string,
taskId: number,
file: File,
): Promise<ApiAttachment> {
const form = new FormData()
form.append('file', file)
return (await api.post(`/repos/${repoId}/tasks/${taskId}/files`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
})) as unknown as ApiAttachment
}
async function removeFile(
repoId: string,
taskId: number,
fileId: string,
): Promise<void> {
await api.delete(`/repos/${repoId}/tasks/${taskId}/files/${fileId}`)
}
/* ===================== 승인 워크플로우 (5단계) ===================== */
async function requestApproval(
repoId: string,
taskId: number,
note?: string,
): Promise<ApiTaskDetail> {
return (await api.post(`/repos/${repoId}/tasks/${taskId}/approval/request`, {
...(note ? { note } : {}),
})) as unknown as ApiTaskDetail
}
async function approve(
repoId: string,
taskId: number,
): Promise<ApiTaskDetail> {
return (await api.post(
`/repos/${repoId}/tasks/${taskId}/approval/approve`,
)) as unknown as ApiTaskDetail
}
async function requestChanges(
repoId: string,
taskId: number,
note: string,
): Promise<ApiTaskDetail> {
return (await api.post(`/repos/${repoId}/tasks/${taskId}/approval/changes`, {
note,
})) as unknown as ApiTaskDetail
}
return {
list,
get,
create,
update,
remove,
changeStatus,
addComment,
addReply,
uploadFile,
removeFile,
requestApproval,
approve,
requestChanges,
}
}
+16
View File
@@ -23,14 +23,20 @@ export interface User {
/** 첨부파일 */
export interface Attachment {
/** 첨부 ID(API 연동 시) */
id?: string
name: string
size: string
/** 파일 아이콘 종류 */
kind: 'pdf' | 'zip' | 'doc' | 'img'
/** 다운로드 URL(API 연동 시) */
url?: string
}
/** 체크리스트 항목 */
export interface ChecklistItem {
/** 항목 ID(API 연동 시) */
id?: string
text: string
done: boolean
}
@@ -76,11 +82,15 @@ export interface RepoTask {
checklist?: [number, number]
/** 코멘트 수 */
comments?: number
/** 첨부 파일 수 */
files?: number
assignees: User[]
}
/** 댓글의 답글 */
export interface Reply {
/** 답글 ID(API 연동 시) */
id?: string
author: User
time: string
text: string
@@ -88,6 +98,8 @@ export interface Reply {
/** 댓글 */
export interface Comment {
/** 댓글 ID(API 연동 시) */
id?: string
author: User
/** 작성자 역할 배지(예: 지시자) */
roleBadge?: string
@@ -135,6 +147,8 @@ export interface TaskDetail {
requestedAgo: string
note: string
}
/** 수정 요청 메시지(수정 요청 상태일 때) */
changesNote?: string
issuedLabel: string
}
@@ -463,6 +477,8 @@ export interface MyTaskRow {
title: string
status: TaskStatus
repoName: string
/** 라우팅용 저장소 식별자(slug) */
repoId?: string
/** 담당자 미니 아바타(지시 뷰) */
assignees?: User[]
/** 보조 텍스트(예: "지시 · 한승우", "정민호", "박지훈님이 2시간 전 승인 요청") */
+68 -55
View File
@@ -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;
+69 -16
View File
@@ -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>
+17 -1
View File
@@ -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 -2
View File
@@ -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
+7 -18
View File
@@ -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 {
+314 -51
View File
@@ -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 미적용) 스타일 사용 -->
+1 -8
View File
@@ -76,14 +76,7 @@ const routes: RouteRecordRaw[] = [
meta: { requiresAuth: true },
},
{
// 담당자(작업자) 관점의 업무 상세 — ?state=request|changes|approved
path: '/repos/:repoId/tasks/:taskId/view',
name: 'task-assignee-view',
component: () => import('@/pages/relay/TaskAssigneePage.vue'),
meta: { requiresAuth: true },
},
{
// '내 업무' — 다음 단계에서 구현 예정(현재는 임시 안내)
// '내 업무' — 담당/지시 저장소 횡단 집계
path: '/tasks',
name: 'my-tasks',
component: () => import('@/pages/relay/MyTasksPage.vue'),
+75 -5
View File
@@ -78,6 +78,40 @@ export function progressOf(done: number, total: number): ProgressInfo {
return { pct, level, label }
}
/** 첨부 파일 종류 (배지 라벨/색상 키) */
export type FileKind = 'pdf' | 'zip' | 'doc' | 'img'
/** 파일명(확장자)으로 종류 파생 — 활동/댓글처럼 종류 정보가 없을 때 사용 */
export function attachmentKindOf(name: string): FileKind {
const ext = name.split('.').pop()?.toLowerCase() ?? ''
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'img'
if (ext === 'pdf') return 'pdf'
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return 'zip'
return 'doc'
}
/** 파일 종류별 배지 라벨 + 색상 클래스(.fi-* — 첨부 카드/활동/댓글 공통) */
export function fileBadge(kind: FileKind): { label: string; cls: string } {
const map: Record<FileKind, { label: string; cls: string }> = {
img: { label: 'IMG', cls: 'fi-img' },
pdf: { label: 'PDF', cls: 'fi-pdf' },
zip: { label: 'ZIP', cls: 'fi-zip' },
doc: { label: 'DOC', cls: 'fi-doc' },
}
return map[kind]
}
/** 바이트 수를 사람이 읽기 쉬운 단위로 (예: 19293798 → "18.4 MB") */
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const exp = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const value = bytes / Math.pow(1024, exp)
// B 는 정수, 그 외는 소수 1자리
const text = exp === 0 ? String(Math.round(value)) : value.toFixed(1)
return `${text} ${units[exp]}`
}
/* ============================================================
* 업무(Task) 표시용 파생 값
* ============================================================ */
@@ -103,6 +137,32 @@ export function monthDayKo(input: Date | string): string {
return `${d.getMonth() + 1}${d.getDate()}`
}
/** 활동 피드 날짜 그룹 라벨 (오늘 / 어제 / 이번 주 / 'M월 D일') */
export function dayGroupKo(input: Date | string): string {
const d = typeof input === 'string' ? new Date(input) : input
if (Number.isNaN(d.getTime())) return ''
const startD = new Date(d.getFullYear(), d.getMonth(), d.getDate())
const now = new Date()
const startNow = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const days = Math.round((startNow.getTime() - startD.getTime()) / 86_400_000)
if (days <= 0) return '오늘'
if (days === 1) return '어제'
if (days < 7) return '이번 주'
return monthDayKo(d)
}
/** HTML 특수문자 이스케이프 — 활동 문구에 사용자 입력을 안전하게 삽입할 때 사용 */
export function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/** D-day 강조 단계 (마감 없음/완료 시 '') */
export type DdayLevel = '' | 'urgent' | 'soon' | 'calm'
/** 업무 마감 정보(원시 dueDate + 완료여부 → 표시 라벨들) */
export interface TaskDueInfo {
/** 전체 날짜 라벨 (예: '6월 18일 (목)') — 마감 없으면 '' */
@@ -113,16 +173,18 @@ export interface TaskDueInfo {
listLabel: string
/** 마감 지남 여부(완료 업무는 false) */
overdue: boolean
/** D-day 강조 단계 (urgent: 지남/임박, soon: 3일 내, calm: 그 외) */
ddayLevel: DdayLevel
}
/** dueDate(ISO|null) + 완료여부로 마감 표시 정보 계산 */
export function taskDueInfo(dueDate: string | null, isDone: boolean): TaskDueInfo {
if (!dueDate) {
return { dateLabel: '', dday: '', listLabel: '', overdue: false }
return { dateLabel: '', dday: '', listLabel: '', overdue: false, ddayLevel: '' }
}
const due = new Date(dueDate)
if (Number.isNaN(due.getTime())) {
return { dateLabel: '', dday: '', listLabel: '', overdue: false }
return { dateLabel: '', dday: '', listLabel: '', overdue: false, ddayLevel: '' }
}
const dateLabel = `${due.getMonth() + 1}${due.getDate()}일 (${WEEKDAYS_KO[due.getDay()]})`
@@ -133,12 +195,20 @@ export function taskDueInfo(dueDate: string | null, isDone: boolean): TaskDueInf
const days = Math.round((startDue.getTime() - startNow.getTime()) / 86_400_000)
if (isDone) {
return { dateLabel, dday: '', listLabel: `${monthDayKo(due)} 완료`, overdue: false }
return {
dateLabel,
dday: '',
listLabel: `${monthDayKo(due)} 완료`,
overdue: false,
ddayLevel: '',
}
}
if (days < 0) {
const passed = `${-days}일 지남`
return { dateLabel, dday: passed, listLabel: passed, overdue: true }
return { dateLabel, dday: passed, listLabel: passed, overdue: true, ddayLevel: 'urgent' }
}
const dday = days === 0 ? 'D-DAY' : `D-${days}`
return { dateLabel, dday, listLabel: dday, overdue: false }
// 임박도: 1일 이내 urgent, 3일 이내 soon, 그 외 calm
const ddayLevel: DdayLevel = days <= 1 ? 'urgent' : days <= 3 ? 'soon' : 'calm'
return { dateLabel, dday, listLabel: dday, overdue: false, ddayLevel }
}
+151
View File
@@ -0,0 +1,151 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useActivity } from '@/composables/useActivity'
import {
avatarColor,
dayGroupKo,
escapeHtml,
initialOf,
monthDayKo,
relativeTimeKo,
taskStatusLabel,
} from '@/shared/utils/format'
import type { ApiActivity } from '@/types/activity'
import type {
RepoActivityDay,
RepoActivityItem,
TaskStatus,
} from '@/mock/relay.mock'
// payload(자유형)에서 문자열 값 안전 추출
function pstr(payload: Record<string, unknown>, key: string): string {
const v = payload[key]
return typeof v === 'string' ? v : ''
}
// 행위자/시간 등 공통 필드
function baseOf(api: ApiActivity): Pick<RepoActivityItem, 'initial' | 'color' | 'time'> {
return {
initial: initialOf(api.actor?.name ?? '?'),
color: avatarColor(api.actor?.id ?? ''),
time: relativeTimeKo(api.createdAt),
}
}
// API 활동 → 화면용 RepoActivityItem (type+payload 로 톤/아이콘/문구 조립)
// 사용자 입력(이름/제목/파일명/표시명)은 escapeHtml 로 감싸 XSS 를 방지한다.
function toRepoActivityItem(api: ApiActivity): RepoActivityItem {
const base = baseOf(api)
const actor = escapeHtml(api.actor?.name ?? '알 수 없음')
const p = api.payload
const title = escapeHtml(pstr(p, 'taskTitle'))
const target = escapeHtml(pstr(p, 'targetName'))
switch (api.type) {
case 'repo.created':
return { ...base, tone: 'setting', icon: 'repo', html: `<b>${actor}</b>님이 저장소를 생성했습니다` }
case 'repo.renamed':
return {
...base,
tone: 'setting',
icon: 'pencil',
html: `<b>${actor}</b>님이 표시 제목을 ${escapeHtml(pstr(p, 'newName'))}(으)로 변경했습니다`,
}
case 'repo.visibility_changed':
return {
...base,
tone: 'setting',
icon: 'lock',
html: `<b>${actor}</b>님이 저장소 공개 범위를 <b>${pstr(p, 'visibility') === 'private' ? '비공개' : '공개'}</b>(으)로 변경했습니다`,
}
case 'member.invited':
return { ...base, tone: 'member', icon: 'member-add', html: `<b>${actor}</b>님이 <b>${target}</b>님을 멤버로 초대했습니다` }
case 'member.role_changed':
return {
...base,
tone: 'member',
icon: 'member-check',
html: `<b>${target}</b>님의 역할이 <b>${pstr(p, 'roleType') === 'admin' ? '관리자' : '멤버'}</b>(으)로 변경되었습니다`,
}
case 'member.removed':
return { ...base, tone: 'member', icon: 'member-remove', html: `<b>${actor}</b>님이 <b>${target}</b>님을 멤버에서 제거했습니다` }
case 'task.created':
return { ...base, tone: 'task', icon: 'pencil', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 지시했습니다` }
case 'task.status_changed': {
const to = pstr(p, 'statusTo')
if (to === 'done') {
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 <span class="st done">완료</span>했습니다` }
}
const label = taskStatusLabel(to as TaskStatus)
const cls = to === 'prog' ? 'prog' : ''
return {
...base,
tone: 'task',
icon: 'check',
html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 <span class="st ${cls}">${escapeHtml(label)}</span>(으)로 변경했습니다`,
}
}
case 'task.comment_added':
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무에 댓글을 남겼습니다` }
case 'task.file_attached':
return {
...base,
tone: 'task',
icon: 'check',
html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무에 파일 <b>${escapeHtml(pstr(p, 'fileName'))}</b>을(를) 첨부했습니다`,
}
case 'task.assignees_changed':
return { ...base, tone: 'task', icon: 'pencil', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무의 담당자를 변경했습니다` }
case 'task.due_changed': {
const due = pstr(p, 'dueDate')
const body = due
? `마감기한을 <b>${escapeHtml(monthDayKo(due))}</b>(으)로 설정했습니다`
: `마감기한을 해제했습니다`
return { ...base, tone: 'task', icon: 'pencil', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무의 ${body}` }
}
case 'task.edited':
return { ...base, tone: 'task', icon: 'pencil', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 수정했습니다` }
case 'task.file_removed':
return {
...base,
tone: 'task',
icon: 'pencil',
html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무의 첨부 <b>${escapeHtml(pstr(p, 'fileName'))}</b>을(를) 삭제했습니다`,
}
case 'task.removed':
return { ...base, tone: 'task', icon: 'pencil', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 삭제했습니다` }
default:
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님의 활동` }
}
}
// 활동 스토어 — 저장소 활동 피드(날짜 그룹)
export const useActivityStore = defineStore('activity', () => {
const days = ref<RepoActivityDay[]>([])
const loading = ref(false)
const activityApi = useActivity()
// 저장소 활동 조회 → 날짜 그룹으로 묶기(목록은 최신순이라 같은 날은 연속)
async function fetch(repoId: string): Promise<void> {
loading.value = true
try {
const list = await activityApi.list(repoId)
const groups: RepoActivityDay[] = []
let current: RepoActivityDay | null = null
for (const a of list) {
const day = dayGroupKo(a.createdAt)
if (!current || current.day !== day) {
current = { day, items: [] }
groups.push(current)
}
current.items.push(toRepoActivityItem(a))
}
days.value = groups
} finally {
loading.value = false
}
}
return { days, loading, fetch }
})
+196
View File
@@ -0,0 +1,196 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { useMyTask } from '@/composables/useMyTask'
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
import { avatarColor, initialOf, taskDueInfo, taskStatusLabel } from '@/shared/utils/format'
import type { ApiMember } from '@/types/repo'
import type { ApiMyTaskRow } from '@/types/my-task'
import type {
MyTaskGroup,
MyTaskRow,
TaskStatus,
User as UserView,
} from '@/mock/relay.mock'
// API 멤버 → 화면용 User(이니셜/색상 파생)
function toUserView(m: ApiMember): UserView {
return {
id: m.id,
name: m.name,
role: m.role ?? '',
initial: initialOf(m.name),
color: avatarColor(m.id),
}
}
// 상태 그룹 순서/라벨 — 담당 뷰와 지시 뷰가 다르다(시안 기준)
const ASSIGNED_ORDER: TaskStatus[] = ['changes', 'prog', 'todo', 'review', 'done']
const ISSUED_ORDER: TaskStatus[] = ['review', 'prog', 'changes', 'todo', 'done']
const ASSIGNED_LABEL: Record<TaskStatus, string> = {
changes: '수정 요청',
prog: '진행 중',
todo: '할 일',
review: '승인 대기',
done: '완료',
}
const ISSUED_LABEL: Record<TaskStatus, string> = {
changes: '수정 요청함',
prog: '진행 중',
todo: '할 일',
review: '승인 대기',
done: '완료',
}
// API 행 → 담당 뷰 행 (누가 지시했는지 표시)
function toAssignedRow(api: ApiMyTaskRow): MyTaskRow {
const due = taskDueInfo(api.dueDate, api.status === 'done')
const [, total] = api.checklist
const isDone = api.status === 'done'
return {
id: api.id,
repoId: api.repoId,
title: api.title,
status: api.status,
repoName: api.repoName,
checklist: total > 0 ? api.checklist : undefined,
metaText: `지시 · ${api.issuer?.name ?? '알 수 없음'}`,
dueLabel: isDone ? due.listLabel : due.dateLabel || undefined,
dday: due.dday || undefined,
ddayLevel: due.ddayLevel || undefined,
overdue: due.overdue || undefined,
statusLabel: taskStatusLabel(api.status),
done: isDone || undefined,
}
}
// API 행 → 지시 뷰 행 (담당자 미니 아바타 + 승인 대기 강조)
function toIssuedRow(api: ApiMyTaskRow): MyTaskRow {
const due = taskDueInfo(api.dueDate, api.status === 'done')
const [, total] = api.checklist
const isDone = api.status === 'done'
const isReview = api.status === 'review'
const names = api.assignees.map((a) => a.name).join(', ') || '미지정'
return {
id: api.id,
repoId: api.repoId,
title: api.title,
status: api.status,
repoName: api.repoName,
checklist: total > 0 ? api.checklist : undefined,
assignees: api.assignees.map(toUserView),
metaText: api.status === 'changes' ? `${names}에게 수정 요청` : names,
metaAttn: isReview || undefined,
dueLabel: isDone ? due.listLabel : due.dateLabel || undefined,
dday: due.dday || undefined,
ddayLevel: due.ddayLevel || undefined,
overdue: due.overdue || undefined,
statusLabel: isReview ? undefined : taskStatusLabel(api.status),
reviewBtn: isReview || undefined,
done: isDone || undefined,
}
}
// 행 배열 → 상태 그룹 배열(순서·라벨 적용, 빈 그룹 제외)
function groupRows(
rows: MyTaskRow[],
order: TaskStatus[],
labelMap: Record<TaskStatus, string>,
issued: boolean,
): MyTaskGroup[] {
const groups: MyTaskGroup[] = []
for (const key of order) {
const groupRows = rows.filter((row) => row.status === key)
if (groupRows.length === 0) continue
const group: MyTaskGroup = { key, label: labelMap[key], rows: groupRows }
// 지시 뷰의 승인 대기 그룹은 강조(내 승인 필요)
if (issued && key === 'review') {
group.attn = true
group.action = '· 내 승인 필요'
}
groups.push(group)
}
return groups
}
// 내 업무 스토어 — 담당/지시 두 뷰(상태 그룹) + 더보기 페이지네이션
// 원시 행을 누적하고 그룹핑은 computed 로 파생(더보기 시 같은 그룹에 자연스럽게 합쳐짐)
export const useMyTaskStore = defineStore('myTask', () => {
const assignedRaw = ref<ApiMyTaskRow[]>([])
const issuedRaw = ref<ApiMyTaskRow[]>([])
const assignedTotal = ref(0)
const issuedTotal = ref(0)
const assignedPage = ref(0)
const issuedPage = ref(0)
const loading = ref(false)
const loadingMore = ref(false)
const myTaskApi = useMyTask()
const assigned = computed(() =>
groupRows(assignedRaw.value.map(toAssignedRow), ASSIGNED_ORDER, ASSIGNED_LABEL, false),
)
const issued = computed(() =>
groupRows(issuedRaw.value.map(toIssuedRow), ISSUED_ORDER, ISSUED_LABEL, true),
)
const assignedHasMore = computed(() => assignedRaw.value.length < assignedTotal.value)
const issuedHasMore = computed(() => issuedRaw.value.length < issuedTotal.value)
// 첫 페이지(리셋) — 담당/지시 동시 조회
async function fetchAll(): Promise<void> {
loading.value = true
try {
const [a, i] = await Promise.all([
myTaskApi.assigned(1, DEFAULT_PAGE_SIZE),
myTaskApi.issued(1, DEFAULT_PAGE_SIZE),
])
assignedRaw.value = a.items
assignedTotal.value = a.total
assignedPage.value = 1
issuedRaw.value = i.items
issuedTotal.value = i.total
issuedPage.value = 1
} finally {
loading.value = false
}
}
// 담당 뷰 다음 페이지(더보기)
async function loadMoreAssigned(): Promise<void> {
if (loadingMore.value || !assignedHasMore.value) return
loadingMore.value = true
try {
const a = await myTaskApi.assigned(assignedPage.value + 1, DEFAULT_PAGE_SIZE)
assignedRaw.value.push(...a.items)
assignedTotal.value = a.total
assignedPage.value += 1
} finally {
loadingMore.value = false
}
}
// 지시 뷰 다음 페이지(더보기)
async function loadMoreIssued(): Promise<void> {
if (loadingMore.value || !issuedHasMore.value) return
loadingMore.value = true
try {
const i = await myTaskApi.issued(issuedPage.value + 1, DEFAULT_PAGE_SIZE)
issuedRaw.value.push(...i.items)
issuedTotal.value = i.total
issuedPage.value += 1
} finally {
loadingMore.value = false
}
}
return {
assigned,
issued,
loading,
loadingMore,
assignedHasMore,
issuedHasMore,
fetchAll,
loadMoreAssigned,
loadMoreIssued,
}
})
+31 -4
View File
@@ -1,6 +1,7 @@
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { useRepo } from '@/composables/useRepo'
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
import { avatarColor, initialOf, progressOf, relativeTimeKo } from '@/shared/utils/format'
import type { ApiMember, ApiRepo, CreateRepoPayload, UpdateRepoPayload } from '@/types/repo'
import type { Repo as RepoView, User as UserView } from '@/mock/relay.mock'
@@ -50,6 +51,11 @@ export const useRepoStore = defineStore('repo', () => {
const repos = ref<RepoView[]>([])
const current = ref<RepoView | null>(null)
const loading = ref(false)
// 페이지네이션(더보기) 상태
const total = ref(0)
const page = ref(0)
const loadingMore = ref(false)
const hasMore = computed(() => repos.value.length < total.value)
// 생성 폼 메타 — 소유자(GITEA_ORG)·템플릿 정보. 최초 1회 조회 후 캐시
const owner = ref('')
const templateAvailable = ref(false)
@@ -66,17 +72,33 @@ export const useRepoStore = defineStore('repo', () => {
templateSlug.value = meta.template.slug
}
// 목록 조회
// 목록 첫 페이지 조회(리셋)
async function fetchList(): Promise<void> {
loading.value = true
try {
const list = await repoApi.list()
repos.value = list.map(toRepoView)
const res = await repoApi.list(1, DEFAULT_PAGE_SIZE)
repos.value = res.items.map(toRepoView)
total.value = res.total
page.value = 1
} finally {
loading.value = false
}
}
// 다음 페이지 이어붙이기(더보기)
async function loadMore(): Promise<void> {
if (loadingMore.value || !hasMore.value) return
loadingMore.value = true
try {
const res = await repoApi.list(page.value + 1, DEFAULT_PAGE_SIZE)
repos.value.push(...res.items.map(toRepoView))
total.value = res.total
page.value += 1
} finally {
loadingMore.value = false
}
}
// 단건 조회
async function fetchOne(id: string): Promise<RepoView | null> {
loading.value = true
@@ -104,6 +126,7 @@ export const useRepoStore = defineStore('repo', () => {
async function remove(id: string): Promise<void> {
await repoApi.remove(id)
repos.value = repos.value.filter((r) => r.id !== id)
total.value = Math.max(0, total.value - 1)
if (current.value?.id === id) current.value = null
}
@@ -111,11 +134,15 @@ export const useRepoStore = defineStore('repo', () => {
repos,
current,
loading,
total,
hasMore,
loadingMore,
owner,
templateAvailable,
templateSlug,
fetchCreateMeta,
fetchList,
loadMore,
fetchOne,
create,
update,
+204 -5
View File
@@ -3,8 +3,11 @@ import { defineStore } from 'pinia'
import { useTask } from '@/composables/useTask'
import {
avatarColor,
escapeHtml,
formatBytes,
initialOf,
monthDayKo,
relativeTimeKo,
taskDueInfo,
taskStatusLabel,
} from '@/shared/utils/format'
@@ -12,15 +15,22 @@ import type {
ApiMember,
} from '@/types/repo'
import type {
ApiAttachment,
ApiComment,
ApiRepoTask,
ApiTaskDetail,
CreateTaskPayload,
TaskListQuery,
UpdateTaskPayload,
} from '@/types/task'
import type { ApiActivity } from '@/types/activity'
import type {
Activity as ActivityView,
Attachment as AttachmentView,
Comment as CommentView,
RepoTask as RepoTaskView,
TaskDetail as TaskDetailView,
TaskStatus,
User as UserView,
} from '@/mock/relay.mock'
@@ -56,13 +66,109 @@ function toRepoTaskView(api: ApiRepoTask): RepoTaskView {
overdue: due.overdue || undefined,
checklist: total > 0 ? api.checklist : undefined,
comments: api.commentCount > 0 ? api.commentCount : undefined,
files: api.fileCount > 0 ? api.fileCount : undefined,
assignees: api.assignees.map(toUserView),
}
}
// API 업무 상세 → 화면용 TaskDetail (comments/activities/files/approval 은 5단계)
// API 첨부 → 화면용 Attachment(바이트 → 표시 크기)
function toAttachmentView(a: ApiAttachment): AttachmentView {
return {
id: a.id,
name: a.name,
size: formatBytes(a.size),
kind: a.kind,
url: a.url,
}
}
// API 댓글 → 화면용 Comment(작성자/시간 파생, 지시자면 역할 배지)
function toCommentView(c: ApiComment, issuerId: string | null): CommentView {
return {
id: c.id,
author: c.author ? toUserView(c.author) : UNKNOWN_USER,
roleBadge: c.author && issuerId && c.author.id === issuerId ? '지시자' : undefined,
time: relativeTimeKo(c.createdAt),
text: c.text,
file: c.file?.name,
replies: c.replies.map((r) => ({
id: r.id,
author: r.author ? toUserView(r.author) : UNKNOWN_USER,
time: relativeTimeKo(r.createdAt),
text: r.text,
})),
}
}
// payload(자유형)에서 문자열 값 안전 추출
function pstr(payload: Record<string, unknown>, key: string): string {
const v = payload[key]
return typeof v === 'string' ? v : ''
}
// API 활동 → 업무 상세 활동 탭 뷰(Activity). 사용자 입력은 escapeHtml 로 안전 삽입
function toTaskActivityView(api: ApiActivity): ActivityView {
const actor = escapeHtml(api.actor?.name ?? '알 수 없음')
const p = api.payload
const time = relativeTimeKo(api.createdAt)
switch (api.type) {
case 'task.created':
return { tone: 'accent', icon: 'flag', html: `<b>${actor}</b>님이 업무를 생성했습니다`, time }
case 'task.status_changed': {
const from = pstr(p, 'statusFrom')
const to = pstr(p, 'statusTo')
if (to === 'review') {
return {
tone: 'amber',
icon: 'check-circle',
html: `<b>${actor}</b>님이 승인을 요청했습니다`,
time,
statusFrom: taskStatusLabel(from as TaskStatus),
statusTo: taskStatusLabel(to as TaskStatus),
}
}
if (to === 'done') {
return { tone: 'green', icon: 'check', html: `<b>${actor}</b>님이 업무를 완료(승인)했습니다`, time }
}
if (to === 'changes') {
return { tone: 'amber', icon: 'check-circle', html: `<b>${actor}</b>님이 수정을 요청했습니다`, time }
}
if (to === 'prog') {
return {
tone: 'blue',
icon: 'flag',
html: `<b>${actor}</b>님이 업무를 ${from === 'changes' ? '다시 진행' : '시작'}했습니다`,
time,
}
}
return { tone: '', icon: 'check', html: `<b>${actor}</b>님이 상태를 변경했습니다`, time }
}
case 'task.comment_added':
return { tone: '', icon: 'check', html: `<b>${actor}</b>님이 댓글을 남겼습니다`, time }
case 'task.file_attached':
return { tone: '', icon: 'paperclip', html: `<b>${actor}</b>님이 파일을 첨부했습니다`, time, file: pstr(p, 'fileName') }
case 'task.assignees_changed':
return { tone: 'blue', icon: 'users', html: `<b>${actor}</b>님이 담당자를 변경했습니다`, time }
case 'task.due_changed': {
const due = pstr(p, 'dueDate')
const body = due
? `마감기한을 <b>${escapeHtml(taskDueInfo(due, false).dateLabel)}</b>(으)로 설정했습니다`
: '마감기한을 해제했습니다'
return { tone: 'blue', icon: 'calendar', html: `<b>${actor}</b>님이 ${body}`, time }
}
case 'task.edited':
return { tone: '', icon: 'check', html: `<b>${actor}</b>님이 업무를 수정했습니다`, time }
case 'task.file_removed':
return { tone: '', icon: 'paperclip', html: `<b>${actor}</b>님이 첨부 파일을 삭제했습니다`, time, file: pstr(p, 'fileName') }
default:
return { tone: '', icon: 'check', html: `<b>${actor}</b>님의 활동`, time }
}
}
// API 업무 상세 → 화면용 TaskDetail
function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
const due = taskDueInfo(api.dueDate, api.status === 'done')
const issuerId = api.issuer?.id ?? null
return {
id: api.id,
repoId: api.repoId,
@@ -71,14 +177,24 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
status: api.status,
statusLabel: taskStatusLabel(api.status),
content: api.content,
checklist: api.checklist.map((c) => ({ text: c.text, done: c.done })),
comments: [],
activities: [],
checklist: api.checklist.map((c) => ({ id: c.id, text: c.text, done: c.done })),
comments: api.comments.map((c) => toCommentView(c, issuerId)),
activities: api.activities.map(toTaskActivityView),
assignees: api.assignees.map(toUserView),
issuer: api.issuer ? toUserView(api.issuer) : UNKNOWN_USER,
dueLabel: due.dateLabel,
dday: due.dday,
files: [],
files: api.files.map(toAttachmentView),
approval: api.approval
? {
requester: api.approval.requester?.name ?? '알 수 없음',
requestedAgo: api.approval.requestedAt
? relativeTimeKo(api.approval.requestedAt)
: '',
note: api.approval.note ?? '',
}
: undefined,
changesNote: api.changesNote ?? undefined,
issuedLabel: `${monthDayKo(api.createdAt)} 지시 · #${api.id}`,
}
}
@@ -149,6 +265,82 @@ export const useTaskStore = defineStore('task', () => {
return view
}
// 부속 변경 후 상세를 다시 받아 일관된 뷰로 갱신하는 공통 헬퍼
async function refresh(repoId: string, taskId: number): Promise<TaskDetailView> {
const view = toTaskDetailView(await taskApi.get(repoId, taskId))
current.value = view
return view
}
/* ===================== 댓글 / 답글 (5단계) ===================== */
async function addComment(
repoId: string,
taskId: number,
text: string,
): Promise<TaskDetailView> {
await taskApi.addComment(repoId, taskId, text)
return refresh(repoId, taskId)
}
async function addReply(
repoId: string,
taskId: number,
commentId: string,
text: string,
): Promise<TaskDetailView> {
await taskApi.addReply(repoId, taskId, commentId, text)
return refresh(repoId, taskId)
}
/* ===================== 파일 첨부 (5단계) ===================== */
async function uploadFile(
repoId: string,
taskId: number,
file: File,
): Promise<TaskDetailView> {
await taskApi.uploadFile(repoId, taskId, file)
return refresh(repoId, taskId)
}
async function removeFile(
repoId: string,
taskId: number,
fileId: string,
): Promise<TaskDetailView> {
await taskApi.removeFile(repoId, taskId, fileId)
return refresh(repoId, taskId)
}
/* ===================== 승인 워크플로우 (5단계) ===================== */
async function requestApproval(
repoId: string,
taskId: number,
note?: string,
): Promise<TaskDetailView> {
const view = toTaskDetailView(await taskApi.requestApproval(repoId, taskId, note))
current.value = view
return view
}
async function approve(repoId: string, taskId: number): Promise<TaskDetailView> {
const view = toTaskDetailView(await taskApi.approve(repoId, taskId))
current.value = view
return view
}
async function requestChanges(
repoId: string,
taskId: number,
note: string,
): Promise<TaskDetailView> {
const view = toTaskDetailView(await taskApi.requestChanges(repoId, taskId, note))
current.value = view
return view
}
return {
tasks,
current,
@@ -159,5 +351,12 @@ export const useTaskStore = defineStore('task', () => {
update,
remove,
changeStatus,
addComment,
addReply,
uploadFile,
removeFile,
requestApproval,
approve,
requestChanges,
}
})
+31
View File
@@ -0,0 +1,31 @@
// 활동 피드 도메인 타입 — 백엔드 ActivityResponse 와 1:1
import type { ApiMember } from '@/types/repo'
// 활동 이벤트 타입(백엔드 ActivityType 과 동일 문자열)
export type ApiActivityType =
| 'repo.created'
| 'repo.renamed'
| 'repo.visibility_changed'
| 'member.invited'
| 'member.role_changed'
| 'member.removed'
| 'task.created'
| 'task.status_changed'
| 'task.comment_added'
| 'task.file_attached'
| 'task.assignees_changed'
| 'task.due_changed'
| 'task.edited'
| 'task.file_removed'
| 'task.removed'
// 활동(API 원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립한다
export interface ApiActivity {
id: string
type: ApiActivityType
actor: ApiMember | null
taskSeq: number | null
payload: Record<string, unknown>
createdAt: string
}
+17
View File
@@ -0,0 +1,17 @@
// 내 업무(저장소 횡단) 도메인 타입 — 백엔드 MyTaskRowResponse 와 1:1
import type { TaskStatus } from '@/mock/relay.mock'
import type { ApiMember } from '@/types/repo'
// 내 업무 행(API 원시) — 그룹핑/라벨은 프론트가 파생
export interface ApiMyTaskRow {
id: number // 저장소 내 순번(seq)
repoId: string // slug (라우팅용)
repoName: string
title: string
status: TaskStatus
dueDate: string | null
checklist: [number, number] // [완료, 전체]
assignees: ApiMember[]
issuer: ApiMember | null
}
+11
View File
@@ -0,0 +1,11 @@
// 목록 페이지네이션 공통 타입 — 백엔드 PaginatedResult<T> 와 1:1
export interface Paginated<T> {
items: T[]
total: number
page: number
size: number
}
// 기본 페이지 크기
export const DEFAULT_PAGE_SIZE = 20
+63 -4
View File
@@ -2,6 +2,7 @@
import type { TaskStatus } from '@/mock/relay.mock'
import type { ApiMember } from '@/types/repo'
import type { ApiActivity } from '@/types/activity'
// 업무 목록 행(API 원시) — 백엔드 RepoTaskResponse 와 1:1
export interface ApiRepoTask {
@@ -11,6 +12,7 @@ export interface ApiRepoTask {
dueDate: string | null
checklist: [number, number] // [완료, 전체]
commentCount: number
fileCount: number // 첨부 파일 수
assignees: ApiMember[]
}
@@ -21,7 +23,43 @@ export interface ApiChecklistItem {
done: boolean
}
// 업무 상세(API 원시) — comments/activities/files 는 5단계에서 채움(현재 빈 배열)
// 첨부파일(API 원시) — 백엔드 AttachmentResponse 와 1:1
export interface ApiAttachment {
id: string
name: string
size: number // 바이트
kind: 'pdf' | 'zip' | 'doc' | 'img'
url: string // 다운로드 경로
uploader: ApiMember | null
createdAt: string
}
// 답글(API 원시)
export interface ApiCommentReply {
id: string
author: ApiMember | null
text: string
createdAt: string
}
// 댓글(API 원시, 답글 포함)
export interface ApiComment {
id: string
author: ApiMember | null
text: string
file: ApiAttachment | null
createdAt: string
replies: ApiCommentReply[]
}
// 승인 요청 정보(API 원시)
export interface ApiApproval {
requester: ApiMember | null
requestedAt: string | null
note: string | null
}
// 업무 상세(API 원시) — 백엔드 TaskDetailResponse 와 1:1 (activities 는 6단계)
export interface ApiTaskDetail {
id: number
repoId: string
@@ -34,9 +72,11 @@ export interface ApiTaskDetail {
issuer: ApiMember | null
dueDate: string | null
createdAt: string
comments: unknown[]
activities: unknown[]
files: unknown[]
comments: ApiComment[]
activities: ApiActivity[]
files: ApiAttachment[]
approval: ApiApproval | null
changesNote: string | null
}
// 업무 목록 필터
@@ -60,4 +100,23 @@ export interface UpdateTaskPayload {
content?: string[]
assigneeIds?: string[]
dueDate?: string | null
// 체크리스트 전체 동기화(id 있으면 기존 유지, 없으면 신규, 빠지면 삭제)
checklist?: { id?: string; text: string }[]
}
// 댓글/답글 작성 요청(5단계)
export interface AddCommentPayload {
text: string
fileId?: string
}
export interface AddReplyPayload {
text: string
}
// 승인 워크플로우 요청(5단계)
export interface ApprovalRequestPayload {
note?: string
}
export interface ApprovalChangesPayload {
note: string
}