feat: 업무(Task) 모듈 4단계 — CRUD·상태머신·진행률 집계·역할 기반 상세 UI

백엔드
- Task/ChecklistItem 엔티티(저장소 내 순번 seq, jsonb content, assignees ManyToMany, checklist)
- GET/POST/PATCH/DELETE /repos/:repoId/tasks[/:taskId] + POST .../status
- 상태 머신(todo→prog→review→done, review→changes, changes→{review,prog}) + 전이별 역할 검증
- 담당자=저장소 멤버만 지정, 승인 완료(→done) 시 체크리스트 일괄 완료(영속)
- 저장소 doneCount/totalCount·멤버 taskCount 실집계(RepoModule→TaskModule)
- Task.repo FK 컬럼명 snake_case 고정(@JoinColumn), task.service 단위 테스트

프론트
- types/task·useTask·task.store, format.ts 라벨 파생(상태/마감/dday)
- RepoDetailPage 업무 목록, TaskCreatePage 생성/수정 겸용(편집 라우트)
- TaskDetailPage 역할 기반 액션 분기(지시자 승인/수정요청, 담당자 시작/승인요청)
- 액션 카드·상태 뱃지 디자인 시안 정렬, 작성폼/목록 UI 정리

문서: api-contract·integration-progress 4단계 반영

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 18:12:11 +09:00
parent c01f8e535c
commit 875eddf659
27 changed files with 2466 additions and 342 deletions
+83 -5
View File
@@ -5,14 +5,17 @@ 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 GiteaRepoBar from '@/components/GiteaRepoBar.vue'
import { useRepoStore } from '@/stores/repo.store'
import { findRepoTasks, type Repo, type TaskStatus } from '@/mock/relay.mock'
import { useTaskStore } from '@/stores/task.store'
import type { Repo, TaskStatus } from '@/mock/relay.mock'
type Segment = 'all' | 'prog' | 'todo' | 'done'
const route = useRoute()
const repoId = computed(() => String(route.params.repoId))
const repoStore = useRepoStore()
const taskStore = useTaskStore()
// 저장소 헤더/진행률 — API 연동
const repo = ref<Repo | null>(null)
@@ -23,10 +26,25 @@ async function loadRepo() {
repo.value = null // 404/403 등은 인터셉터가 알림 처리
}
}
watch(repoId, loadRepo, { immediate: true })
// 업무 목록 — 4단계(업무 API)에서 교체 예정, 현재는 목업
const tasks = computed(() => findRepoTasks(repoId.value))
// 업무 목록 — API 연동
async function loadTasks() {
try {
await taskStore.fetchList(repoId.value)
} catch {
// 인터셉터가 알림 처리
}
}
watch(
repoId,
() => {
loadRepo()
loadTasks()
},
{ immediate: true },
)
const tasks = computed(() => taskStore.tasks)
const memberCount = computed(() =>
repo.value ? repo.value.members.length + repo.value.moreCount : 0,
)
@@ -121,6 +139,13 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
</template>
</RepoHeader>
<!-- Gitea 클론 주소/열기 (연동 시에만 표시) -->
<GiteaRepoBar
:clone-url="repo.cloneUrl"
:html-url="repo.htmlUrl"
class="gitea-bar-spacing"
/>
<!-- 서브탭(공통) -->
<RepoTabs
:repo-id="repo.id"
@@ -189,10 +214,41 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
</svg>
마감 임박순
</div>
<RouterLink
class="btn primary new-task"
:to="`/repos/${repo.id}/tasks/new`"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
업무
</RouterLink>
</div>
<!-- 업무 목록 -->
<div class="list">
<div
v-if="taskStore.loading && tasks.length === 0"
class="state-msg"
>
업무를 불러오는
</div>
<div
v-else-if="filteredTasks.length === 0"
class="state-msg"
>
{{ tasks.length === 0 ? '아직 등록된 업무가 없습니다. 새 업무를 지시해 보세요.' : '조건에 맞는 업무가 없습니다.' }}
</div>
<div
v-else
class="list"
>
<RouterLink
v-for="task in filteredTasks"
:key="task.id"
@@ -334,6 +390,9 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
.crumb {
padding: 1.125rem 0 0.75rem;
}
.gitea-bar-spacing {
margin-top: 0.75rem;
}
/* 진행률 스트립 (RepoHeader 의 extra 슬롯에 주입 — 부모 스코프 스타일 적용) */
.prog-strip {
@@ -376,6 +435,16 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
.toolbar .search {
width: 15rem;
}
.new-task {
/* .sort 의 margin-left:auto 가 오른쪽 그룹을 밀어내므로 여기엔 auto 를 두지 않는다
(이중 auto 는 여유 공간을 분배해 필터/정렬 버튼이 떨어져 보이는 원인) */
border: none;
text-decoration: none;
}
.new-task svg {
width: 0.9375rem;
height: 0.9375rem;
}
/* 업무 행 */
.task-row {
@@ -472,4 +541,13 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
text-align: center;
color: var(--text-2);
}
.state-msg {
padding: 2.5rem 1.125rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-3);
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
}
</style>
+46 -9
View File
@@ -20,6 +20,9 @@ onMounted(() => {
void repoStore.fetchCreateMeta()
})
// Gitea 연동 여부 — 저장소 중 하나라도 htmlUrl 이 있으면 연동된 것으로 간주
const giteaConnected = computed(() => repos.value.some((r) => !!r.htmlUrl))
// 검색어 + 공개여부 필터 적용
const filteredRepos = computed(() =>
repos.value.filter((repo) => {
@@ -43,7 +46,10 @@ const filteredRepos = computed(() =>
<div class="pagehead">
<h1>저장소</h1>
<span class="count-pill">{{ repos.length }}</span>
<span class="gitea-badge">
<span
v-if="giteaConnected"
class="gitea-badge"
>
<svg
viewBox="0 0 24 24"
fill="none"
@@ -201,6 +207,25 @@ const filteredRepos = computed(() =>
</svg>
{{ repo.visibility === 'private' ? '비공개' : '공개' }}
</span>
<span
v-if="repo.giteaMissing"
class="missing-badge"
title="Gitea 조직에서 원격 저장소를 찾을 수 없습니다."
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
<path d="M15 7h2a5 5 0 0 1 0 10h-2" />
<path d="m2 2 20 20" />
</svg>
원격 없음
</span>
</div>
<div class="repo-desc">
{{ repo.desc }}
@@ -261,9 +286,6 @@ const filteredRepos = computed(() =>
:style="{ width: repo.progressPct + '%' }"
/>
</div>
<div class="stat-pct">
{{ repo.progressLabel }}
</div>
</div>
<span class="chev">
@@ -447,11 +469,6 @@ const filteredRepos = computed(() =>
.repo-stat .pbar {
width: 9.75rem;
}
.stat-pct {
font-size: 0.719rem;
color: var(--text-3);
white-space: nowrap;
}
.chev {
color: var(--text-3);
flex-shrink: 0;
@@ -468,4 +485,24 @@ const filteredRepos = computed(() =>
color: var(--text-3);
font-size: 0.844rem;
}
/* 원격(Gitea) 없음 배지 */
.missing-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
color: var(--red);
background: var(--red-weak);
border: 1px solid var(--red-border);
padding: 0.125rem 0.4375rem;
border-radius: 20px;
line-height: 1.4;
white-space: nowrap;
}
.missing-badge svg {
width: 0.75rem;
height: 0.75rem;
}
</style>
+119 -48
View File
@@ -1,38 +1,46 @@
<script setup lang="ts">
// 업무 상세 — 담당자(작업자) 관점. state 쿼리로 3가지 상태를 분기한다.
// - request : 진행 중 → 지시자에게 승인 요청
// - changes : 수정 요청됨 → 다시 승인 요청
// - approved: 승인 완료
import { computed, onMounted, onUnmounted, ref } from 'vue'
// 업무 상세 — 담당자(작업자) 관점. 실제 task.status 로 카드/액션을 분기한다.
// - todo : 업무 시작(todo→prog)
// - prog : 승인 요청(prog→review)
// - review: 승인 대기 중(액션 없음)
// - changes: 다시 승인 요청(changes→review)
// - done : 승인 완료
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import { TASK_DETAIL } from '@/mock/relay.mock'
import { useTaskStore } from '@/stores/task.store'
import type { TaskDetail } from '@/mock/relay.mock'
// 카드 스타일 분기용 뷰 상태(승인 요청 / 수정 요청 / 승인 완료)
type ViewState = 'request' | 'changes' | 'approved'
const route = useRoute()
const router = useRouter()
const repoId = computed(() => String(route.params.repoId))
const task = TASK_DETAIL
const taskId = computed(() => Number(route.params.taskId))
// 쿼리에서 상태 결정(미지정/오류 시 request)
const state = computed<ViewState>(() => {
const s = String(route.query.state ?? 'request')
return s === 'changes' || s === 'approved' ? s : 'request'
const taskStore = useTaskStore()
// 업무 상세 — API 연동
const task = ref<TaskDetail | null>(null)
async function loadTask() {
try {
task.value = await taskStore.fetchOne(repoId.value, taskId.value)
} catch {
task.value = null
}
}
watch(taskId, loadTask, { immediate: true })
// 실제 상태 → 카드 뷰 상태 매핑 (todo/prog/review → request 카드)
const viewState = computed<ViewState>(() => {
const s = task.value?.status
if (s === 'done') return 'approved'
if (s === 'changes') return 'changes'
return 'request'
})
// 상태별 표시 구성
const CONFIG: Record<ViewState, { badge: string; badgeLabel: string; statusLabel: string }> = {
request: { badge: 'prog', badgeLabel: '진행 중', statusLabel: '진행 중' },
changes: { badge: 'changes', badgeLabel: '수정 요청', statusLabel: '수정 요청' },
approved: { badge: 'done', badgeLabel: '완료', statusLabel: '완료' },
}
const cfg = computed(() => CONFIG[state.value])
// 완료 상태면 체크리스트 전부 완료로 표시
const checklist = computed(() =>
state.value === 'approved' ? task.checklist.map((c) => ({ ...c, done: true })) : task.checklist,
)
const checklist = computed(() => task.value?.checklist ?? [])
const checklistDone = computed(() => checklist.value.filter((c) => c.done).length)
const checklistPct = computed(() =>
checklist.value.length ? Math.round((checklistDone.value / checklist.value.length) * 100) : 0,
@@ -44,9 +52,32 @@ const showDelete = ref(false)
const showRequest = ref(false)
const commentText = ref('')
function confirmDelete() {
// 상태 전이 — 담당자: 시작(todo→prog) / 승인요청(prog→review) / 재요청(changes→review)
async function changeTo(status: TaskDetail['status']) {
if (!task.value) return
try {
task.value = await taskStore.changeStatus(repoId.value, task.value.id, status)
} catch {
// 인터셉터가 알림 처리
}
}
async function startTask() {
await changeTo('prog')
}
async function submitApprovalRequest() {
showRequest.value = false
await changeTo('review')
}
async function confirmDelete() {
if (!task.value) return
showDelete.value = false
router.push(`/repos/${repoId.value}`)
try {
await taskStore.remove(repoId.value, task.value.id)
router.push(`/repos/${repoId.value}`)
} catch {
// 인터셉터가 알림 처리
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
@@ -78,7 +109,10 @@ function renderMention(text: string): string {
<AppShell>
<!-- 본문 v-html 내부에서 생성한 신뢰 마크업(멘션 강조) 렌더한다 -->
<!-- eslint-disable vue/no-v-html -->
<div class="page">
<div
v-if="task"
class="page"
>
<div class="crumb">
<RouterLink
to="/repos"
@@ -109,8 +143,8 @@ function renderMention(text: string): string {
<div class="ph-titlerow">
<span
class="st-badge"
:class="cfg.badge"
>{{ cfg.badgeLabel }}</span>
:class="task.status"
>{{ task.statusLabel }}</span>
<span class="ph-tid">#{{ task.id }}</span>
</div>
<h1 class="ph-title">
@@ -120,7 +154,7 @@ function renderMention(text: string): string {
<div class="actions">
<RouterLink
class="btn"
:to="`/repos/${repoId}/tasks/new`"
:to="`/repos/${repoId}/tasks/${task.id}/edit`"
>
<svg
viewBox="0 0 24 24"
@@ -352,12 +386,12 @@ function renderMention(text: string): string {
<!-- 상태별 승인 카드 -->
<div
class="card ap-card"
:class="state"
:class="viewState"
>
<div class="ap-card-head">
<span class="ap-card-ico">
<svg
v-if="state === 'request'"
v-if="viewState === 'request'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -366,7 +400,7 @@ function renderMention(text: string): string {
stroke-linejoin="round"
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
<svg
v-else-if="state === 'changes'"
v-else-if="viewState === 'changes'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -384,12 +418,34 @@ function renderMention(text: string): string {
stroke-linejoin="round"
><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><path d="M22 4 12 14.01l-3-3" /></svg>
</span>
<span class="ap-card-title">{{ state === 'request' ? '지시자에게 승인 요청' : state === 'changes' ? '수정 요청됨' : '승인 완료' }}</span>
<span class="ap-card-title">{{ viewState === 'request' ? '내 작업' : viewState === 'changes' ? '수정 요청됨' : '승인 완료' }}</span>
</div>
<template v-if="state === 'request'">
<!-- 승인 대기 (이미 요청 보냄) -->
<template v-if="task.status === 'review'">
<div class="ap-card-meta">
지시자에게 승인을 요청했습니다. 검토 결과를 기다리는 중입니다.
</div>
</template>
<!-- 업무 시작 -->
<template v-else-if="task.status === 'todo'">
<div class="ap-card-desc">
작업을 마쳤다 지시자 <b>정태경</b>님에게 검토 승인을 요청하세요. 승인되면 업무가 완료 처리됩니다.
아직 시작하지 않은 업무입니다. 작업을 시작하 상태가 <b>진행 </b>으로 바뀝니다.
</div>
<div class="ap-card-actions">
<button
class="ap-btn request-send"
type="button"
@click="startTask"
>
업무 시작
</button>
</div>
</template>
<!-- 진행 승인 요청 -->
<template v-else-if="task.status === 'prog'">
<div class="ap-card-desc">
작업을 마쳤다면 지시자에게 검토 승인을 요청하세요. 승인되면 업무가 완료 처리됩니다.
</div>
<div class="ap-card-actions">
<button
@@ -409,13 +465,10 @@ function renderMention(text: string): string {
</button>
</div>
</template>
<template v-else-if="state === 'changes'">
<!-- 수정 요청됨 다시 승인 요청 -->
<template v-else-if="task.status === 'changes'">
<div class="ap-card-meta">
<b>정태경</b>님이 수정을 요청했습니다 · 1시간
</div>
<div class="ap-card-note">
세로형 카피 가독성이 낮으니 자간과 대비를 조정해주세요. 모바일 적응형까지 포함해 다시 올려주세요.
지시자가 수정을 요청했습니다. 보완 다시 승인을 요청하세요.
</div>
<div class="ap-card-actions">
<button
@@ -435,10 +488,10 @@ function renderMention(text: string): string {
</button>
</div>
</template>
<!-- 완료 -->
<template v-else>
<div class="ap-card-meta">
<b>정태경</b>님이 검토를 승인했습니다 · 방금 · 해야 일이 모두 완료되었습니다.
지시자가 검토를 승인했습니다. 업무가 완료되었습니다.
</div>
</template>
</div>
@@ -450,8 +503,8 @@ function renderMention(text: string): string {
</div>
<span
class="status-badge"
:class="state"
><span class="dot" />{{ cfg.statusLabel }}</span>
:class="task.status"
><span class="dot" />{{ task.statusLabel }}</span>
</div>
<div class="side-field">
<div class="side-label">
@@ -496,7 +549,10 @@ function renderMention(text: string): string {
</div>
</div>
<div class="card side-card">
<div
v-if="task.files.length"
class="card side-card"
>
<div class="side-field">
<div class="side-label">
첨부파일 · {{ task.files.length }}
@@ -538,6 +594,16 @@ function renderMention(text: string): string {
</div>
</div>
<!-- 로딩/없음 상태 -->
<div
v-else
class="page"
>
<p class="task-empty">
업무를 불러오는 중이거나 찾을 없습니다.
</p>
</div>
<!-- 모달 -->
<Teleport to="body">
<!-- 삭제 -->
@@ -566,7 +632,7 @@ function renderMention(text: string): string {
업무를 삭제할까요?
</h2>
<p class="modal-desc">
<b>#{{ task.id }} {{ task.title }}</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 작업은 되돌릴 없습니다.
<b>#{{ task?.id }} {{ task?.title }}</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 작업은 되돌릴 없습니다.
</p>
</div>
<div class="modal-foot">
@@ -622,7 +688,7 @@ function renderMention(text: string): string {
<button
class="mbtn request-send"
type="button"
@click="showRequest = false"
@click="submitApprovalRequest"
>
요청 보내기
</button>
@@ -637,6 +703,11 @@ function renderMention(text: string): string {
.crumb {
padding: 1rem 0 0;
}
.task-empty {
padding: 3rem 0;
text-align: center;
color: var(--text-2);
}
/* 페이지 헤더 */
.pagehead {
+352 -149
View File
@@ -1,40 +1,147 @@
<script setup lang="ts">
// 업무 작성 — 제목/내용/체크리스트 + 사이드(담당자/마감/첨부/지시자)
import { computed, ref } from 'vue'
// 업무 작성/수정 — 제목/내용/체크리스트 + 사이드(담당자/마감/지시자) · 백엔드 연동
// taskId 파라미터가 있으면 '수정 모드'로 동작(기존 값 미리 채움 + PATCH 저장)
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import { findRepo, USERS, type Attachment, type ChecklistItem, type User } from '@/mock/relay.mock'
import { useRepoStore } from '@/stores/repo.store'
import { useMemberStore } from '@/stores/member.store'
import { useTaskStore } from '@/stores/task.store'
import { useAuthStore } from '@/stores/auth.store'
import { useTask } from '@/composables/useTask'
import { avatarColor, initialOf } from '@/shared/utils/format'
import type { Repo, ChecklistItem, User } from '@/mock/relay.mock'
import type { ApiMember } from '@/types/repo'
const route = useRoute()
const router = useRouter()
const repoId = computed(() => String(route.params.repoId))
const repo = computed(() => findRepo(repoId.value))
// 수정 모드 여부 — taskId 파라미터 존재 시 편집
const taskId = computed(() => (route.params.taskId ? Number(route.params.taskId) : null))
const isEdit = computed(() => taskId.value !== null)
// 제목
const title = ref('3분기 신제품 런칭 캠페인 소재 제작')
const repoStore = useRepoStore()
const memberStore = useMemberStore()
const taskStore = useTaskStore()
const authStore = useAuthStore()
const taskApi = useTask()
// 담당자 (칩)
const assignees = ref<User[]>([USERS.kim, USERS.park, USERS.lee])
// API 멤버 → 화면용 User(이니셜/색상 파생)
function toUserView(m: ApiMember): User {
return {
id: m.id,
name: m.name,
role: m.role ?? '',
initial: initialOf(m.name),
color: avatarColor(m.id),
}
}
// ISO → input[type=date] 용 'yyyy-MM-dd'
function toDateInput(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
// 저장소 헤더(빵부스러기) — API 연동
const repo = ref<Repo | null>(null)
async function loadRepo() {
try {
repo.value = await repoStore.fetchOne(repoId.value)
} catch {
repo.value = null
}
}
// 담당자 후보 = 저장소 멤버
async function loadMembers() {
try {
await memberStore.fetchList(repoId.value)
} catch {
// 인터셉터가 알림 처리
}
}
// 수정 모드: 기존 업무 값을 폼에 미리 채운다
async function loadTaskForEdit() {
if (taskId.value === null) return
try {
const t = await taskApi.get(repoId.value, taskId.value)
title.value = t.title
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 }))
} catch {
// 404 등은 인터셉터가 알림 처리
}
}
watch(
[repoId, taskId],
() => {
loadRepo()
loadMembers()
loadTaskForEdit()
},
{ immediate: true },
)
// 제목 / 내용
const title = ref('')
const contentText = ref('')
// 지시자 = 현재 로그인 사용자
const issuer = computed<User>(() => {
const u = authStore.user
return {
id: u?.id ?? '',
name: u?.name ?? '나',
role: u?.role ?? '',
initial: initialOf(u?.name ?? '나'),
color: avatarColor(u?.id ?? ''),
}
})
// --- 담당자 선택 (저장소 멤버 중) ---
const assignees = ref<User[]>([])
const assigneeQuery = ref('')
const showAssigneeDropdown = ref(false)
function removeAssignee(id: string) {
assignees.value = assignees.value.filter((a) => a.id !== id)
}
// 아직 선택되지 않은 멤버 후보(이름 부분일치)
const assigneeCandidates = computed(() => {
const selected = new Set(assignees.value.map((a) => a.id))
const kw = assigneeQuery.value.trim()
return memberStore.members
.filter((m) => !selected.has(m.user.id))
.filter((m) => !kw || m.user.name.includes(kw) || m.email.includes(kw))
})
function addAssignee(userId: string) {
const m = memberStore.members.find((x) => x.user.id === userId)
if (m && !assignees.value.some((a) => a.id === userId)) {
assignees.value.push(m.user)
}
assigneeQuery.value = ''
showAssigneeDropdown.value = false
}
function onAssigneeBlur() {
// 항목은 @mousedown.prevent 로 선택하므로 약간 지연 후 닫는다
setTimeout(() => (showAssigneeDropdown.value = false), 120)
}
// 체크리스트
const checklist = ref<ChecklistItem[]>([
{ text: '캠페인 핵심 메시지 3개 시안 작성', done: true },
{ text: '레퍼런스 리서치 및 무드보드 정리', done: true },
{ text: '메인 배너 이미지 2종 (가로/세로) 디자인', done: false },
{ text: '상세페이지 카피라이팅 및 법무 검토', done: false },
{ text: '최종 산출물 공유 드라이브 업로드', done: false },
])
// --- 마감기한 ---
const dueDate = ref('') // yyyy-MM-dd (input[type=date])
// 체크리스트 (생성 시 항목만 등록 — 완료여부는 항상 false 로 생성)
const checklist = ref<ChecklistItem[]>([])
const newItem = ref('')
const doneCount = computed(() => checklist.value.filter((c) => c.done).length)
const progressPct = computed(() =>
checklist.value.length ? Math.round((doneCount.value / checklist.value.length) * 100) : 0,
)
function toggleItem(item: ChecklistItem) {
item.done = !item.done
}
function removeItem(index: number) {
checklist.value.splice(index, 1)
}
@@ -45,25 +152,51 @@ function addItem() {
newItem.value = ''
}
// 첨부파일
const files = ref<Attachment[]>([
{ name: '캠페인_브리프_v2.pdf', size: '2.4 MB', kind: 'pdf' },
{ name: '브랜드_가이드라인.pdf', size: '8.1 MB', kind: 'pdf' },
{ name: '레퍼런스_이미지.zip', size: '34.7 MB', kind: 'zip' },
])
function removeFile(index: number) {
files.value.splice(index, 1)
// --- 제출 ---
const submitting = ref(false)
const canSubmit = computed(
() => !submitting.value && title.value.trim().length > 0 && assignees.value.length > 0,
)
async function submitTask() {
if (!canSubmit.value) return
submitting.value = true
try {
const content = contentText.value
.split('\n')
.map((p) => p.trim())
.filter((p) => p.length > 0)
const assigneeIds = assignees.value.map((a) => a.id)
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,
})
router.push(`/repos/${repoId.value}/tasks/${updated.id}`)
} else {
const created = await taskStore.create(repoId.value, {
title: title.value.trim(),
content: content.length ? content : undefined,
assigneeIds,
dueDate: dueIso ?? undefined,
checklist: checklist.value.length
? checklist.value.map((c) => ({ text: c.text }))
: undefined,
})
router.push(`/repos/${repoId.value}/tasks/${created.id}`)
}
} catch {
// 인터셉터가 알림 처리(권한/검증 등)
} finally {
submitting.value = false
}
}
// 파일 종류 → 아이콘 클래스/라벨
const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
pdf: { cls: 'fi-pdf', label: 'PDF' },
zip: { cls: 'fi-zip', label: 'ZIP' },
doc: { cls: 'fi-doc', label: 'DOC' },
img: { cls: 'fi-doc', label: 'IMG' },
}
// 취소 / 지시 보내기 — 추후 API 연동, 현재는 저장소 상세로 이동
function goBack() {
router.push(`/repos/${repoId.value}`)
}
@@ -87,12 +220,11 @@ function goBack() {
{{ repo?.name ?? '저장소' }}
</RouterLink>
<span class="sep">/</span>
업무
{{ isEdit ? '수정' : '새 업무' }}
</div>
<div class="pagehead">
<h1> 업무 작성</h1>
<span class="draft-tag">임시저장됨 · 방금</span>
<h1>{{ isEdit ? '업무 수정' : '새 업무 작성' }}</h1>
<div class="actions">
<button
class="btn ghost"
@@ -101,16 +233,11 @@ function goBack() {
>
취소
</button>
<button
class="btn"
type="button"
>
임시저장
</button>
<button
class="btn primary"
type="button"
@click="goBack"
:disabled="!canSubmit"
@click="submitTask"
>
<svg
width="15"
@@ -125,7 +252,7 @@ function goBack() {
<path d="m22 2-7 20-4-9-9-4Z" />
<path d="M22 2 11 13" />
</svg>
지시 보내기
{{ submitting ? (isEdit ? '저장 중…' : '전송 중…') : (isEdit ? '저장' : '지시 보내기') }}
</button>
</div>
</div>
@@ -226,11 +353,11 @@ function goBack() {
><path d="m16 18 6-6-6-6M8 6l-6 6 6 6" /></svg>
</button>
</div>
<div class="editor-body">
<p>9 신제품(라인 클렌저) 정식 출시에 맞춰 사용할 캠페인 소재 일체를 제작합니다. 톤앤매너는 <span class="mention">@브랜드_가이드라인.pdf</span> 기준을 따라주세요.</p>
<p>퍼포먼스 광고용 배너와 자사몰 상세페이지를 우선순위로 진행하고, SNS 콘텐츠는 1 시안 확정 착수합니다. 카피는 "민감 피부 저자극" 메시지를 핵심으로 잡되, 과장 광고 표현은 법무 검토를 반드시 거쳐주세요.</p>
<p> 산출물은 아래 체크리스트 기준으로 관리하며, 진행 상황은 업무 상세에서 코멘트로 공유 바랍니다.</p>
</div>
<textarea
v-model="contentText"
class="editor-body"
placeholder="업무 배경과 요구사항을 작성하세요. (줄바꿈은 문단으로 저장됩니다)"
/>
</div>
</div>
@@ -260,7 +387,6 @@ function goBack() {
<span
class="cbox"
:class="{ done: item.done }"
@click="toggleItem(item)"
>
<svg
viewBox="0 0 24 24"
@@ -273,6 +399,7 @@ function goBack() {
</span>
<span class="txt">{{ item.text }}</span>
<span
v-if="!isEdit"
class="row-del"
@click="removeItem(index)"
>
@@ -287,7 +414,10 @@ function goBack() {
</span>
</div>
</div>
<div class="add-row">
<div
v-if="!isEdit"
class="add-row"
>
<span class="plus">+</span>
<input
v-model="newItem"
@@ -296,6 +426,12 @@ function goBack() {
@keydown.enter="addItem"
>
</div>
<p
v-else
class="checklist-note"
>
체크리스트 항목 편집은 추후 단계에서 지원됩니다.
</p>
</div>
</section>
@@ -307,25 +443,15 @@ function goBack() {
<span class="req">*</span> 담당자 <span class="muted">· {{ assignees.length }}</span>
</div>
<div class="combo">
<!-- 입력칸은 고정 선택된 담당자는 아래에 표기 -->
<div class="combo-field">
<span
v-for="a in assignees"
:key="a.id"
class="chip"
>
<span
class="avatar"
:class="a.color"
>{{ a.initial }}</span>
{{ a.name }}
<span
class="x"
@click="removeAssignee(a.id)"
>×</span>
</span>
<input
v-model="assigneeQuery"
class="combo-input"
placeholder="이름 검색…"
placeholder="멤버 검색…"
autocomplete="off"
@focus="showAssigneeDropdown = true"
@blur="onAssigneeBlur"
>
<span class="combo-caret">
<svg
@@ -338,6 +464,53 @@ function goBack() {
><path d="m6 9 6 6 6-6" /></svg>
</span>
</div>
<!-- 멤버 후보 드롭다운 -->
<div
v-if="showAssigneeDropdown"
class="assignee-dd"
>
<button
v-for="m in assigneeCandidates"
:key="m.user.id"
type="button"
class="assignee-dd-item"
@mousedown.prevent="addAssignee(m.user.id)"
>
<span
class="avatar"
:class="m.user.color"
>{{ m.user.initial }}</span>
<span class="ad-nm">{{ m.user.name }}</span>
<span class="ad-em">{{ m.email }}</span>
</button>
<div
v-if="assigneeCandidates.length === 0"
class="assignee-dd-msg"
>
{{ memberStore.members.length === 0 ? '저장소 멤버가 없습니다.' : '추가할 멤버가 없습니다.' }}
</div>
</div>
</div>
<!-- 선택된 담당자 (입력칸 아래에 표기) -->
<div
v-if="assignees.length"
class="assignee-chips"
>
<span
v-for="a in assignees"
:key="a.id"
class="chip"
>
<span
class="avatar"
:class="a.color"
>{{ a.initial }}</span>
{{ a.name }}
<span
class="x"
@click="removeAssignee(a.id)"
>×</span>
</span>
</div>
</div>
</div>
@@ -345,65 +518,21 @@ function goBack() {
<div class="card side-card">
<div class="side-field">
<div class="side-label">
<span class="req">*</span> 마감기한
</div>
<div class="input">
<span class="ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><rect
x="3"
y="4"
width="18"
height="18"
rx="2"
/><path d="M16 2v4M8 2v4M3 10h18" /></svg>
</span>
2026 6 30 () 18:00
</div>
<div class="date-meta">
<span class="dot" /> 마감까지 15 남음
마감기한 <span class="muted">· 선택</span>
</div>
<input
v-model="dueDate"
class="input date-input"
type="date"
>
</div>
<div class="side-field">
<div class="side-label">
첨부파일 <span class="muted">· {{ files.length }}</span>
첨부파일 <span class="muted">· 5단계 예정</span>
</div>
<div class="files">
<div
v-for="(file, index) in files"
:key="file.name"
class="file"
>
<span
class="file-ico"
:class="FILE_META[file.kind].cls"
>{{ FILE_META[file.kind].label }}</span>
<span class="file-info">
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ file.size }}</span>
</span>
<span
class="x"
@click="removeFile(index)"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</span>
</div>
</div>
<div class="dropzone">
파일을 끌어다 놓거나 <b>찾아보기</b>
<div class="dropzone is-disabled">
파일 첨부는 제공됩니다
</div>
</div>
</div>
@@ -414,14 +543,18 @@ function goBack() {
지시자
</div>
<div class="issuer">
<span class="avatar av-f"></span>
<span
class="avatar"
:class="issuer.color"
>{{ issuer.initial }}</span>
<span>
<div class="nm">정태경</div>
<div class="rl">마케팅 그룹 리드</div>
<div class="nm">{{ issuer.name }}</div>
</span>
</div>
<div class="side-foot">
지시를 보내면 담당자 {{ assignees.length }}명에게 알림이 전송되고, 업무가 각자의 업무 추가됩니다.
{{ isEdit
? '변경 사항을 저장하면 담당자에게 반영됩니다.'
: `지시를 보내면 담당자 ${assignees.length}명에게 알림이 전송되고, 업무가 각자의 ‘내 업무’에 추가됩니다.` }}
</div>
</div>
</div>
@@ -452,17 +585,6 @@ function goBack() {
white-space: nowrap;
flex-shrink: 0;
}
.draft-tag {
font-size: 0.719rem;
font-weight: 600;
color: var(--amber);
background: var(--amber-weak);
border: 1px solid var(--amber-border);
padding: 0.1875rem 0.5rem;
border-radius: 20px;
line-height: 1;
white-space: nowrap;
}
.pagehead .actions {
margin-left: auto;
display: flex;
@@ -573,17 +695,22 @@ function goBack() {
margin: 0 0.3125rem;
}
.editor-body {
display: block;
width: 100%;
box-sizing: border-box;
padding: 0.875rem 1rem;
font-size: 0.875rem;
font-family: inherit;
color: var(--text);
min-height: 9.375rem;
line-height: 1.65;
border: none;
outline: none;
background: transparent;
resize: vertical;
}
.editor-body p {
margin-bottom: 0.6875rem;
}
.editor-body p:last-child {
margin-bottom: 0;
.editor-body::placeholder {
color: var(--text-3);
}
.mention {
color: var(--accent);
@@ -648,7 +775,6 @@ function goBack() {
display: grid;
place-items: center;
background: #fff;
cursor: pointer;
}
.cbox.done {
background: var(--green);
@@ -720,6 +846,11 @@ function goBack() {
.add-input::placeholder {
color: var(--text-3);
}
.checklist-note {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-3);
}
/* 사이드바 */
.side {
@@ -762,6 +893,76 @@ function goBack() {
.combo {
position: relative;
}
/* 멤버 후보 드롭다운 */
.assignee-dd {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
right: 0;
z-index: 10;
background: #fff;
border: 1px solid var(--border);
border-radius: 0.5625rem;
box-shadow: 0 12px 32px rgba(20, 24, 33, 0.16);
padding: 0.375rem;
max-height: 14rem;
overflow-y: auto;
}
.assignee-dd-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.4375rem 0.5rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
text-align: left;
}
.assignee-dd-item:hover {
background: #f5f6f8;
}
.assignee-dd-item .avatar {
width: 1.5rem;
height: 1.5rem;
font-size: 0.625rem;
flex-shrink: 0;
}
.ad-nm {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.ad-em {
font-size: 0.719rem;
color: var(--text-3);
margin-left: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 9rem;
}
.assignee-dd-msg {
padding: 0.625rem 0.5rem;
font-size: 0.75rem;
color: var(--text-3);
text-align: center;
}
.date-input {
cursor: pointer;
}
.dropzone.is-disabled {
cursor: default;
opacity: 0.7;
}
.dropzone.is-disabled:hover {
border-color: var(--border-strong);
background: #fafbfc;
color: var(--text-3);
}
.combo-field {
border: 1px solid var(--border-strong);
border-radius: var(--radius);
@@ -787,6 +988,13 @@ function goBack() {
width: 1rem;
height: 1rem;
}
/* 선택된 담당자 칩 — 입력칸 아래에 표기 */
.assignee-chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.5rem;
}
.chip {
display: inline-flex;
align-items: center;
@@ -973,11 +1181,6 @@ function goBack() {
font-size: 0.8125rem;
font-weight: 600;
}
.issuer .rl {
font-size: 0.719rem;
color: var(--text-3);
white-space: nowrap;
}
.side-foot {
font-size: 0.719rem;
color: var(--text-3);
+382 -31
View File
@@ -15,27 +15,45 @@ import {
import { useRoute, useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import {
TASK_DETAIL,
USERS,
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'
const route = useRoute()
const router = useRouter()
const repoId = computed(() => String(route.params.repoId))
const task = TASK_DETAIL
const taskId = computed(() => Number(route.params.taskId))
// 상태 배지 클래스(시안의 prog/review/done/changes 와 동일)
const statusClass = computed<TaskStatus>(() => task.status)
const taskStore = useTaskStore()
const authStore = useAuthStore()
// 업무 상세 — API 연동
const task = ref<TaskDetail | null>(null)
async function loadTask() {
try {
task.value = await taskStore.fetchOne(repoId.value, taskId.value)
} catch {
task.value = null // 404 등은 인터셉터가 알림 처리
}
}
watch(taskId, loadTask, { immediate: true })
// 상태 배지 클래스(prog/review/done/changes)
const statusClass = computed<TaskStatus>(() => task.value?.status ?? 'todo')
// 체크리스트 진행률
const checklistDone = computed(() => task.checklist.filter((c) => c.done).length)
const checklistPct = computed(() =>
task.checklist.length ? Math.round((checklistDone.value / task.checklist.length) * 100) : 0,
)
const checklistDone = computed(() => task.value?.checklist.filter((c) => c.done).length ?? 0)
const checklistPct = computed(() => {
const total = task.value?.checklist.length ?? 0
return total ? Math.round((checklistDone.value / total) * 100) : 0
})
// 본문 멘션(@…)을 강조 span 으로 변환
function renderMention(text: string): string {
@@ -85,10 +103,54 @@ function closeAllOverlays() {
moreOpen.value = false
}
// 모달에서 삭제 확정 저장소 상세로 이동(목업)
function confirmDelete() {
// 모달에서 삭제 확정 → DELETE 후 저장소 상세로 이동
async function confirmDelete() {
if (!task.value) return
showDelete.value = false
router.push(`/repos/${repoId.value}`)
try {
await taskStore.remove(repoId.value, task.value.id)
router.push(`/repos/${repoId.value}`)
} catch {
// 인터셉터가 알림 처리
}
}
// 승인(review→done) / 수정요청(review→changes) — 지시자 액션
async function approveTask() {
if (!task.value) return
try {
task.value = await taskStore.changeStatus(repoId.value, task.value.id, 'done')
} catch {
// 인터셉터가 알림 처리
} finally {
showApprove.value = false
}
}
async function requestChanges() {
if (!task.value) return
try {
task.value = await taskStore.changeStatus(repoId.value, task.value.id, 'changes')
} catch {
// 인터셉터가 알림 처리
} finally {
showChanges.value = false
}
}
// 담당자 액션 — 업무 시작(todo→prog) / 승인 요청(prog→review) / 다시 승인 요청(changes→review)
async function changeTo(status: TaskStatus) {
if (!task.value) return
try {
task.value = await taskStore.changeStatus(repoId.value, task.value.id, status)
} catch {
// 인터셉터가 알림 처리
}
}
function startTask() {
void changeTo('prog')
}
function requestApproval() {
void changeTo('review')
}
/* ============================================================
@@ -96,13 +158,44 @@ function confirmDelete() {
* ============================================================ */
const activeTab = ref<'comments' | 'activity'>('comments')
// 댓글은 로컬 상태로 복제해 답글/등록을 반영
const comments = reactive<Comment[]>(task.comments.map((c) => ({ ...c, replies: [...c.replies] })))
// 댓글 — 5단계 실연동 예정. 현재는 로컬 상태 데모(새로고침 시 비어 있음)
const comments = reactive<Comment[]>([])
const replyOpen = ref<number | null>(null)
const replyText = ref('')
const commentText = ref('')
const me = USERS.jung // 현재 사용자(지시자)
// 현재 로그인 사용자 (댓글 작성자)
const me = computed<User>(() => {
const u = authStore.user
return {
id: u?.id ?? '',
name: u?.name ?? '나',
role: u?.role ?? '',
initial: initialOf(u?.name ?? '나'),
color: avatarColor(u?.id ?? ''),
}
})
// 현재 사용자의 이 업무에 대한 역할(액션 분기용)
const isIssuer = computed(
() => !!task.value && !!me.value.id && task.value.issuer.id === me.value.id,
)
const isAssignee = computed(
() => !!task.value && task.value.assignees.some((a) => a.id === me.value.id),
)
// 담당자 액션 카드 테마(시안): changes→빨강 / review→앰버 / done→초록 / 그 외→accent
const assigneeCardClass = computed(() => {
switch (task.value?.status) {
case 'changes':
return 'changes'
case 'review':
return 'review'
case 'done':
return 'done'
default:
return 'request'
}
})
function toggleReply(index: number) {
replyOpen.value = replyOpen.value === index ? null : index
@@ -112,7 +205,7 @@ function submitReply(index: number) {
const text = replyText.value.trim()
const target = comments[index]
if (!text || !target) return
const reply: Reply = { author: me, time: '방금', text }
const reply: Reply = { author: me.value, time: '방금', text }
target.replies.push(reply)
replyText.value = ''
replyOpen.value = null
@@ -120,7 +213,7 @@ function submitReply(index: number) {
function submitComment() {
const text = commentText.value.trim()
if (!text) return
comments.push({ author: me, roleBadge: '지시자', time: '방금', text, replies: [] })
comments.push({ author: me.value, roleBadge: '지시자', time: '방금', text, replies: [] })
commentText.value = ''
}
@@ -387,7 +480,10 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
</button>
</template>
<div class="page">
<div
v-if="task"
class="page"
>
<div class="crumb">
<RouterLink
to="/repos"
@@ -429,7 +525,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
<div class="actions">
<RouterLink
class="btn"
:to="`/repos/${repoId}/tasks/new`"
:to="`/repos/${repoId}/tasks/${task.id}/edit`"
>
<svg
viewBox="0 0 24 24"
@@ -791,9 +887,9 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
<!-- 사이드바 -->
<aside class="side">
<!-- 승인 요청 카드 -->
<!-- 지시자: 승인 대기(review) 승인/수정요청 -->
<div
v-if="task.approval"
v-if="task.status === 'review' && isIssuer"
class="card ap-card review"
>
<div class="ap-card-head">
@@ -814,10 +910,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
<span class="ap-card-title">승인 요청 도착</span>
</div>
<div class="ap-card-meta">
<b>{{ task.approval.requester }}</b>님이 작업 완료 승인을 요청했습니다 · {{ task.approval.requestedAgo }}
</div>
<div class="ap-card-note">
{{ task.approval.note }}
담당자가 작업 완료 승인을 요청했습니다. 검토 승인하거나 수정을 요청하세요.
</div>
<div class="ap-card-actions">
<button
@@ -853,12 +946,173 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
</div>
</div>
<!-- 담당자: 시작 / 승인요청 / 재요청 / 대기중 / 완료 -->
<div
v-else-if="isAssignee"
class="card ap-card"
:class="assigneeCardClass"
>
<!-- 업무 시작 -->
<template v-if="task.status === 'todo'">
<div class="ap-card-head">
<span class="ap-card-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M5 3 19 12 5 21V3z" /></svg>
</span>
<span class="ap-card-title">업무 시작</span>
</div>
<div class="ap-card-desc">
아직 시작하지 않은 업무입니다. 작업을 시작하면 상태가 <b>진행 </b>으로 바뀌고 지시자에게 표시됩니다.
</div>
<div class="ap-card-actions">
<button
class="ap-btn request-send"
type="button"
@click="startTask"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M5 3 19 12 5 21V3z" /></svg>
업무 시작
</button>
</div>
</template>
<!-- 진행 승인 요청 -->
<template v-else-if="task.status === 'prog'">
<div class="ap-card-head">
<span class="ap-card-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
</span>
<span class="ap-card-title">지시자에게 승인 요청</span>
</div>
<div class="ap-card-desc">
작업을 마쳤다면 지시자 <b>{{ task.issuer.name }}</b>님에게 검토 승인을 요청하세요. 승인되면 업무가 완료 처리됩니다.
</div>
<div class="ap-card-actions">
<button
class="ap-btn request-send"
type="button"
@click="requestApproval"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
승인 요청 보내기
</button>
</div>
</template>
<!-- 수정 요청됨 다시 승인 요청 -->
<template v-else-if="task.status === 'changes'">
<div class="ap-card-head">
<span class="ap-card-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M3 7v6h6" /><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13" /></svg>
</span>
<span class="ap-card-title">수정 요청됨</span>
</div>
<div class="ap-card-meta">
지시자 <b>{{ task.issuer.name }}</b>님이 수정을 요청했습니다. 보완 다시 승인을 요청하세요.
</div>
<div class="ap-card-actions">
<button
class="ap-btn request-send"
type="button"
@click="requestApproval"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
다시 승인 요청
</button>
</div>
</template>
<!-- 승인 대기 -->
<template v-else-if="task.status === 'review'">
<div class="ap-card-head">
<span class="ap-card-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 8v4l3 2" /><circle
cx="12"
cy="12"
r="9"
/></svg>
</span>
<span class="ap-card-title">승인 대기 </span>
</div>
<div class="ap-card-desc">
지시자에게 승인을 요청했습니다. 검토 결과를 기다리는 중입니다.
</div>
</template>
<!-- 완료 -->
<template v-else>
<div class="ap-card-head">
<span class="ap-card-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><path d="M22 4 12 14.01l-3-3" /></svg>
</span>
<span class="ap-card-title">승인 완료</span>
</div>
<div class="ap-card-meta">
지시자 <b>{{ task.issuer.name }}</b>님이 검토를 승인했습니다. 업무가 완료되었습니다.
</div>
</template>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">
상태
</div>
<span class="status-badge review"><span class="dot" />{{ task.statusLabel }}</span>
<span
class="status-badge"
:class="task.status"
><span class="dot" />{{ task.statusLabel }}</span>
</div>
<div class="side-field">
<div class="side-label">
@@ -903,7 +1157,10 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
</div>
</div>
<div class="card side-card">
<div
v-if="task.files.length"
class="card side-card"
>
<div class="side-field">
<div class="side-label">
첨부파일 · {{ task.files.length }}
@@ -945,6 +1202,16 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
</div>
</div>
<!-- 로딩/없음 상태 -->
<div
v-else
class="page"
>
<p class="task-empty">
업무를 불러오는 중이거나 찾을 없습니다.
</p>
</div>
<!-- ===== 모달 ===== -->
<Teleport to="body">
<!-- 삭제 -->
@@ -973,7 +1240,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
업무를 삭제할까요?
</h2>
<p class="modal-desc">
<b>#{{ task.id }} {{ task.title }}</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 작업은 되돌릴 없습니다.
<b>#{{ task?.id }} {{ task?.title }}</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 작업은 되돌릴 없습니다.
</p>
</div>
<div class="modal-foot">
@@ -1021,7 +1288,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
업무를 승인할까요?
</h2>
<p class="modal-desc">
<b>{{ task.approval?.requester }}</b>님의 검토 요청을 승인합니다. 업무 상태가 <b>완료</b> 변경되고 담당자에게 알림이 전송됩니다.
검토 요청을 승인합니다. 업무 상태가 <b>완료</b> 변경되고 담당자에게 알림이 전송됩니다.
</p>
</div>
<div class="modal-foot">
@@ -1035,7 +1302,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
<button
class="mbtn approve"
type="button"
@click="showApprove = false"
@click="approveTask"
>
승인
</button>
@@ -1077,7 +1344,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
<button
class="mbtn danger"
type="button"
@click="showChanges = false"
@click="requestChanges"
>
수정 요청 보내기
</button>
@@ -1419,6 +1686,11 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
.crumb {
padding: 1rem 0 0;
}
.task-empty {
padding: 3rem 0;
text-align: center;
color: var(--text-2);
}
/* 페이지 헤더 */
.pagehead {
@@ -2075,6 +2347,30 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
.status-badge.review .dot {
background: var(--amber);
}
.status-badge.todo {
color: var(--text-2);
background: #f1f2f4;
border-color: var(--border-strong);
}
.status-badge.todo .dot {
background: var(--text-3);
}
.status-badge.changes {
color: var(--red);
background: var(--red-weak);
border-color: var(--red-border);
}
.status-badge.changes .dot {
background: var(--red);
}
.status-badge.done {
color: var(--green);
background: var(--green-weak);
border-color: #cde8d8;
}
.status-badge.done .dot {
background: var(--green);
}
.assignees {
display: flex;
flex-direction: column;
@@ -2209,6 +2505,42 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
border-color: var(--amber-border);
background: var(--amber-weak);
}
/* 담당자 액션 카드(accent 테마) — 디자인 시안의 .ap-card.request */
.ap-card.request {
border-color: var(--accent-border);
background: var(--accent-weak);
}
.ap-card.request .ap-card-ico {
color: var(--accent);
border-color: var(--accent-border);
}
.ap-card.request .ap-card-title {
color: var(--accent-hover);
}
/* 수정 요청됨(빨강 테마) */
.ap-card.changes {
border-color: var(--red-border);
background: var(--red-weak);
}
.ap-card.changes .ap-card-ico {
color: var(--red);
border-color: var(--red-border);
}
.ap-card.changes .ap-card-title {
color: var(--red);
}
/* 승인 완료(초록 테마) */
.ap-card.done {
border-color: #cde8d8;
background: var(--green-weak);
}
.ap-card.done .ap-card-ico {
color: var(--green);
border-color: #cde8d8;
}
.ap-card.done .ap-card-title {
color: #167a44;
}
.ap-card-head {
display: flex;
align-items: center;
@@ -2245,6 +2577,16 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
color: var(--text);
font-weight: 600;
}
.ap-card-desc {
font-size: 0.781rem;
color: var(--text-2);
margin-top: 0.625rem;
line-height: 1.55;
}
.ap-card-desc b {
color: var(--text);
font-weight: 600;
}
.ap-card-note {
font-size: 0.781rem;
color: var(--text);
@@ -2295,6 +2637,15 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
.ap-btn.approve:hover {
background: #188a4b;
}
.ap-btn.request-send {
background: var(--accent);
border-color: var(--accent);
color: #fff;
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
}
.ap-btn.request-send:hover {
background: var(--accent-hover);
}
</style>
<!-- 모달/에이전트 패널은 Teleport body 렌더되므로 전역(scoped 미적용) 스타일 사용 -->